Create SPA with Laravel and Vue - Part 2 (Backend)

2 June 2018
So now, when you have installed and configured all required software we can finally get to work!

First thing we need to do is create a new Laravel project. We can easily achieve that with Laragon. Use RMB on main window, then select 'Quick create' and 'Laravel'.


Enter project name and hit 'OK'.


Now wait and see Laragon magic! It will create new Laravel project, install Composer dependencies, set application key and create database. It will also add virtual host with pretty url (as shown below). 
When everything is finished you should see following lines in your terminal:
(Laragon) Project path: C:/laragon/www/boilerplate
(Laragon) Pretty url: http://boilerplate.test
Now we can finally start coding!

Launch VS Code by typing:
code .

Open .env and change database credentials:
DB_DATABASE=boilerplate
DB_USERNAME=root
DB_PASSWORD=

Now we are ready to create our first model, controller and migration. Open terminal by hitting CTRL + ~ and type:
php artisan make:model Page -rcm

We'll also need model, controller (this time without resource) and migration for images:
php artisan make:model Image -cm

Next we need to create our database tables by modyfing following file **DATE**_create_pages.table.php:
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreatePagesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('pages', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('order')->unsigned();
            $table->string('slug')->unique();
            $table->string('title')->unique();
            $table->longtext('content');
            $table->boolean('nav')->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('pages');
    }
}
We will use 'nav' to decide whether page should appear in navigation.

**DATE**_create_images.table.php:
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateImagesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('images', function (Blueprint $table) {
            $table->increments('id');
            $table->string('url');
            $table->integer('size');
            $table->boolean('from_dropzone')->nullable();
            $table->string('form_token')->nullable();
            $table->morphs('imageable');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('images');
    }
}
  • 'from_dropzone'  is to determine if image comes from Dropzone or from WYSIWYG 
  • 'form_token'  is unique token generated by frontend. As Dropzone works asynchronously we need to somehow connect uploaded images to current page
  • we'll use polymorphic relation to easily add images to other models as app evolves, last field is for that 

**DATE**_create_users.table.php:
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->increments('id');
            $table->string('username')->unique();
            $table->string('password');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('users');
    }
}

Modify UserFactory.php:
$factory->define(App\User::class, function (Faker $faker) {
    return [
        'username' => $faker->userName,
        'password' => '$2y$10$TKh8H1.PfQx37YgCzwiKb.KjNyWgaHb9cbcoQgdIVFlYg7B77UdFm', // secret
    ];
});

Modify DatabaseSeeder.php:
public function run()
{
	factory(App\User::class)->create([
		'username' => 'admin'
	  ]);
}

Now we need to run the migrations and seed admin user:
php artisan migrate --seed

Next we will move to our models. Go to Page.php and type:
namespace App;

use Illuminate\Database\Eloquent\Model;

class Page extends Model
{
      protected $fillable = [
        'title', 'content', 'order', 'nav'
      ];
    
      public function images()
      {
        return $this->morphMany('App\Image', 'imageable');
      }
}
We are setting fillable fields in order to use create() function as well as polymorphic relation with images.

Image.php:
namespace App;

use Illuminate\Database\Eloquent\Model;

class Image extends Model
{
  public $timestamps = false;

  protected $thumbnail_url, $full_url;

  protected $fillable = [
    'url', 'original_url', 'size', 'imageable_id', 'imageable_type', 'form_token', 'from_dropzone'
  ];

  public function imageable()
  {
    return $this->morphTo();
  }
}
I've decided to remove timestamps (created at & updated at) for this table and add two additional attributes: $thumbnail_url and $full_url that we'll use to easily manage urls.

Now we can move to controllers. Open PageController.php and type:
namespace App\Http\Controllers;

use App\Http\Requests\PageRequest;
use App\Http\Resources\PageResource;
use App\Page;
use App\Image;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;

class PageController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        return PageResource::collection(Page::orderBy('order', 'asc')->get());
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \App\Http\Requests\PageRequest  $request
     * @return \Illuminate\Http\Response
     */
    public function store(PageRequest $request)
    {
        $page = Page::create($request->all() + ['order' => Page::max('order') + 1]);

        Image::where('form_token', $request->form_token)
              ->update(['imageable_id' => $page->id, 'form_token' => NULL]);

        return new PageResource($page);
    }

    /**
     * Display the specified resource.
     *
     * @param  String  $slug
     * @return \Illuminate\Http\Response
     */
    public function show(string $slug)
    {
       if($slug == 'undefined')
            $page = Page::where('nav', 1)->orderBy('order', 'asc')->first();
       else         
            $page = Page::where('slug', $slug)->first();
            
       if($page)
            return new PageResource($page);
        else
            return response('Page not found', 404);
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \App\Http\Requests\PageRequest  $request
     * @param  Integer  $id
     * @return \Illuminate\Http\Response
     */
    public function update(PageRequest $request, int $id)
    {
        $page = Page::findOrFail($id);
        
        if($page->update($request->all()))
            return new PageResource($page);
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  Integer  $id
     * @return \Illuminate\Http\Response
     */
    public function destroy(int $id)
    {
        $page = Page::findOrFail($id);
       
        $images = Image::where([
            ['imageable_id', $page->id],
            ['imageable_type', 'page']
        ])->get();
    
          if($images->isNotEmpty())
          {
            foreach($images as $image)
            {
              if(File::exists(public_path('/photos/upload/' . $image->url)))
                File::delete(public_path('/photos/upload/' . $image->url));
              if(File::exists(public_path('/photos/upload/thumbs/' . $image->url)))
                File::delete(public_path('/photos/upload/thumbs/' . $image->url));
    
              $image->delete();
            }
          }

        if($page->delete())
            return new PageResource($page);
    }

    /**
     * Reorder pages (response from drag & drop).
     *
     * @param \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function reorder(Request $request)
    {
        foreach ($request->order as $index => $newOrder) {
            $page = Page::findOrFail($newOrder);
            $page->order = $index + 1;
            $page->save();
        }
        return 'Order updated!';
    }

    /**
     * Get visible pages for nav
     *
     * @return \Illuminate\Http\Response
     */
    public function getNav()
    {
        return Page::where('nav', 1)->orderBy('order', 'asc')->get(['title', 'slug']);
    }
}
We'll be using PageRequest to validate input data, and PageResource to control data passed to response after HTTP call to our API.

ImageController.php:
namespace App\Http\Controllers;

use App\Image;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Carbon\Carbon;
use Illuminate\Support\Facades\File;
use Intervention\Image\Facades\Image as ImageLib;

class ImageController extends Controller
{
  public function store(Request $request)
  {
    if($request->hasFile('file'))
    {
      $imageFile = $request->file('file');
      $imageName = uniqid() . '-' . str_slug(preg_replace('/\\.[^.\\s]{3,4}$/', '', 
                   $imageFile->getClientOriginalName())) . '.' . $imageFile->getClientOriginalExtension();
      
      $imageFile->move(public_path('photos/upload'), $imageName);

      $image = Image::create([
        'url' => $imageName,
        'size' => File::size(public_path('photos/upload'), $imageName),
        'imageable_type' => $request->type,
        'imageable_id' => $request->id,
        'from_dropzone' => $request->from_dropzone,
        'form_token' => $request->token,
      ]);

      ImageLib::make(public_path('/photos/upload/' . $imageName))
      ->fit(265, 149)
      ->save(public_path('/photos/upload/thumbs/' . $imageName));

      return $imageName;

    }
  }

  public function destroy(Request $request)
  {
    $image = Image::where('url', $request->url)->first();

    if($image)
    {
      if(File::exists(public_path('/photos/upload/' . $request->url)))
        File::delete(public_path('/photos/upload/' . $request->url));
      if(File::exists(public_path('/photos/upload/thumbs/' . $request->url)))
        File::delete(public_path('/photos/upload/thumbs/' . $request->url));

      $image->delete();

      return 'Image deleted';
    }
    else
      return 'Image not found';
  }
}

Next we need to make our custom Request. Create new file - Http/Requests/PageRequest.php and type:
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class PageRequest extends FormRequest
{
  /**
  * Determine if the user is authorized to make this request.
  *
  * @return bool
  */
  public function authorize()
  {
    return true;
  }

  /**
  * Get the validation rules that apply to the request.
  *
  * @return array
  */
  public function rules()
  {
    return [
      'title' => 'unique:pages,title,' . $this->id,
      'content' => 'required',
    ];
  }

  public function messages()
  {
    return [
      'title.required' => __("Field 'title' is required"),
      'title.unique' => __("Field 'title' must be unique"),
      'content.required' => __("Field 'content' is required"),
    ];
  }
}
I'm using translation strings for messages.

Now we'll make our resources. Create new file - Http/Resources/PageResource.php and type:
namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\Resource;

class PageResource extends Resource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'content' => $this->content,
            'short_text' => substr(strip_tags($this->content), 0, 150) . '...',
            'nav' => $this->nav,
            'images' => ImageResource::collection($this->images)
        ]; 
    }
}

Next, create new file - Http/Resources/ImageResource.php and type:
namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\Resource;

class ImageResource extends Resource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return [
            'url' => $this->url,
            'size' => $this->size,
            'dropzone' => $this->from_dropzone
        ]; 
    }
}

As you can see, with resources you control data passed to response. In this case we removed timestamps, slug, and order from pages, modified content (stripped down to 150 characters) and added images to collection modelled by ImageResource.

Next we need to add routes to our API. Go to routes/api.php and type:
Route::apiResources([
    'pages' => 'PageController'
]);

Route::post('images/store', 'ImageController@store');
Route::delete('images/destroy', 'ImageController@destroy');

Route::post('pages/reorder', 'PageController@reorder');
Route::get('pages/get-nav', 'PageController@getNav');
I'm using apiResources() to remove unused routes and methods (create and edit) as it's not necessary for API.

List routes by typing:
php artisan route:list

You should see following list:
+--------+-----------+------------------+---------------+---------------------------------------------+--------------+
| Domain | Method    | URI              | Name          | Action                                      | Middleware   |
+--------+-----------+------------------+---------------+---------------------------------------------+--------------+
|        | GET|HEAD  | /                |               | Closure                                     | web          |
|        | GET|HEAD  | api/pages        | pages.index   | App\Http\Controllers\PageController@index   | api          |
|        | POST      | api/pages        | pages.store   | App\Http\Controllers\PageController@store   | api          |
|        | GET|HEAD  | api/pages/{page} | pages.show    | App\Http\Controllers\PageController@show    | api          |
|        | PUT|PATCH | api/pages/{page} | pages.update  | App\Http\Controllers\PageController@update  | api          |
|        | DELETE    | api/pages/{page} | pages.destroy | App\Http\Controllers\PageController@destroy | api          |
|        | GET|HEAD  | api/user         |               | Closure                                     | api,auth:api |
+--------+-----------+------------------+---------------+---------------------------------------------+--------------+

Last thing we need to do is to create slug from title every time page is created or updated. I like to do it in AppServiceProvider.php. We will also create morph map for our polymorphic relation, auto increment order and disable wrapping resources into additional data array:
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Http\Resources\Json\Resource;
use Illuminate\Database\Eloquent\Relations\Relation;
use App\Page;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
         // Set morph map for polymorphic relation
         Relation::morphMap([
          'page' => 'App\Page'
         ]);

         // Automatically create slug before add and update specified entities
         $this->createSlugs(['Page']);

         // Disable additional data[] on resources
         Resource::withoutWrapping();
    }

    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    public function createSlugs($modelNames) 
	{
        foreach($modelNames as $modelName)
        {
          $model = app("App\\$modelName");
    
          $model::creating(function($instance) use ($model) {
            $instance->slug = str_slug($instance->title, '-');
            $instance->order = $model::max('order') + 1;
            return true;
          });
    
          $model::updating(function($instance) {
            $instance->slug = str_slug($instance->title, '-');
            return true;
          });
        }
      }
	}

Now we are ready to test our API with Advanced REST Client!

Index:




Show:



Store:



Update:



Delete:



Next, we will secure our backend with JSON Web Tokens.
2018 | korek.tech
Made with and SSR