Lesson 1: Laravel API CRUD best practice

In the tutorial we use laravel as API only with features (notice that the Laravel version is 8.*):

  • Register, login, logout user
  • Authentication by Sanctum package
  • CRUD api example
  • Handle exception
  • Bind middleware for api request
  • Handle upload file
  • Fetch output response by API Resource
  • Relation between Model (Video belong to User)
  • Using database transaction

For authentication use sanctum package

composer require laravel/sanctum

Next, you should publish the Sanctum configuration and migration files using the vendor:publish Artisan command. The sanctum configuration file will be placed in your application’s config directory:

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

Change config to default is api at /config/auth.php

'defaults' => [
        'guard' => 'api',
        'passwords' => 'users',
    ],

For api prefix should be version so add each version in one file. For example v1 is routes/api.php and v2 is routes/apiv2.php
Register api prefix in /app/Providers/RouteServiceProvider.php

public function boot()
    {
        $this->configureRateLimiting();

        $this->routes(function () {
            Route::prefix('v1')
                ->middleware('api')
                ->namespace($this->namespace)
                ->group(base_path('routes/api.php'));
            
            Route::prefix('v2')
                ->middleware('api')
                ->namespace($this->namespace)
                ->group(base_path('routes/apiv2.php'));

            Route::middleware('web')
                ->namespace($this->namespace)
                ->group(base_path('routes/web.php'));
        });
    }

Route for v1 /routes/api.php
Use Route::apiResource instead of default Route::resource to exclude routes that present HTML templates such as create and edit
Use middleware auth:sanctum for authentication

Route::post('/login', 'App\Http\Controllers\API\WordpressAuthController@login');
Route::post('/register', 'App\Http\Controllers\API\WordpressAuthController@register');
Route::middleware(['auth:sanctum'])->group(function(){
    Route::get('/user', 'App\Http\Controllers\API\WordpressAuthController@currentUser');       
    Route::post('/logout', 'App\Http\Controllers\API\WordpressAuthController@logout');   
    Route::apiResource('/video', 'App\Http\Controllers\API');
});

The routes will like below

Verb URI Action Route Name
POST /v1/register register
POST /v1/login login
GET /v1/user currentUser
POST /v1/logout logout
GET /v1/videos index videos.index
POST /v1/videos store videos.store
GET /v1/videos/{video} show videos.show
PUT/PATCH /v1/videos/{video} update videos.update
DELETE /v1/videos/{video} destroy videos.destroy

Middleware
Generate middleware to handle api request by command (optional)

php artisan make:middleware Api

Change handle function of middleware /app/Middleware/Api.php

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Auth\Access\Gate;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Response;

class Api
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next, $role = "view")
    {
        $request->headers->set('Accept', 'application/json');
        return $next($request);
    }
}

Add the middleware to all routes in $middlewareGroup inside /app/Http/Kernel.php

/**
     * The application's route middleware groups.
     *
     * @var array
     */
    protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            \Laravel\Jetstream\Http\Middleware\AuthenticateSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \App\Http\Middleware\VerifyCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
            \App\Http\Middleware\HandleInertiaRequests::class,
        ],

        'api' => [
            // 'throttle:api',
            App\Http\Middleware\Api::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
    ];

Controller and Model
Generate auth controller by command

php artisan make:controller /API/AuthController

Controller Api Auth

<?php

namespace App\Http\Controllers\API;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Hash;

class AuthController extends Controller
{
    public function currentUser(Request $request){
        $user = $request->user();
        return $user;
    }
    
    public function register(Request $request)
    {
        $validatedData = $request->validate([
                'email' => 'email|required|unique:wp_users',
                'password' => 'required',
            ]);

        $validatedData['password'] = Hash::make($validatedData['user_password']);
        $user = User::create($validatedData);

        $accessToken = $user->createToken('authToken')->plainTextToken;

        return response([ 'user' => $user, 'access_token' => $accessToken]);
    }

    public function login(Request $request)
    {
        $loginData = $request->validate([
            'email' => 'email|required',
            'password' => 'required'
        ]);
        $user = User::where('email', $request->email)->first();

        if (! $user || ! Hash::check($request->password, $user->password)) {
            return response(['message' => 'Invalid password' ],400);
        }
        $accessToken = $user->createToken('authToken')->plainTextToken;

        return response(['user' => $user, 'access_token' => $accessToken]);

    }

    public function logout(Request $request){
        $request->user()->currentAccessToken()->delete(); 
        return response(['message' => 'Logout Success']);
    }
}

To Add meta for User, we create new table user_meta
Run command create user_meta model and migration

php artisan make:model UserMeta
php artisan make:migration create_user_meta

Modify /app/Models/UserMeta.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Models\User;

class UserMeta extends Model
{
    use HasFactory;
    protected $table = 'user_meta';
    public $timestamps = false;

    protected $fillable = [
        'user_id','photo_path','is_verified'
    ];

    public function user()
    {
        return $this->belongsTo(User::class,'user_id','id');
    }
}

migration user_meta table

public function up()
    {
        Schema::create('user_meta', function (Blueprint $table) {
            $table->id('user_id');
            $table->string('photo_path',500);
            $table->string('photo_verify',500);
            $table->boolean('is_verified');
        });
    }

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

Now to make sure user table is created with meta in same time we use transaction. Modify /app/Http/Controllers/AuthController.php function register

use Illuminate\Support\Facades\DB;
use App\Models\UserMeta;
public function register(Request $request)
    {
        $validatedData = $request->validate([
                'email' => 'email|required|unique:wp_users',
                'password' => 'required',
            ]);

        $validatedData['password'] = Hash::make($validatedData['user_password']);
        $user = DB::transaction(function () use ($validatedData) {
            $user = User::create($validatedData);

            UserMeta::create([
                'user_id' => $user->ID,
                'is_verified' => false,
                'photo_path' => ''
            ]);
            return $user;
        }, 5);
        //5 is time transaction rerun if exception happen

        $accessToken = $user->createToken('authToken')->plainTextToken;

        return response([ 'user' => $user, 'access_token' => $accessToken]);
    }

Before generate VideoController we need to know that we need to response path of video as url that can be show on browser so we need to fetch path as url for each video. This case we need API Resource
Generate VideoResource

php artisan make:resource VideoResource

Change /app/Http/VideoResource.php

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;

class VideoResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'user' => $this->user,
            'title' => $this->title,
            'description' => $this->description,
            'path' => Storage::url($this->path)
        ];
    }
}

Generate video controller and model by command

php artisan make:controller /API/VideoController --resource --api --model=Video


Model /app/Models/Video.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Video extends Model
{
    use HasFactory;
    protected $table = 'api_videos';
    public $timestamps = true;

    protected $casts = [
        'cost' => 'float'
    ];

    protected $fillable = [
        'title',
        'description',
        'path'
    ];

    public function user(){
        return $this->belongsTo(User::class,'user_id','ID');
    }
}

Run command to generate migration table

php artisan make:migration add_video --create=api_videos

After that change migration file

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class AddVideo extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('api_videos', function (Blueprint $table) {
            $table->id();
            $table->string('title',255);
            $table->string('description',2000);
            $table->string('path',500);
            $table->timestamps();
        });
    }

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

resource: create controller with full function for route:resource
api: exclude function that generate html view
model: generate model and inject to the controller

Controller app/http/Controller/API/VideoController.php

<?php

namespace App\Http\Controllers\API;

use App\Http\Controllers\Controller;
use App\Http\Resources\VideoResource;
use App\Models\Video;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class VideoController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        //
        $videos = VideoResource::collection(Video::with('user')->latest()->paginate(5));

        return response(['data'=>$videos]);
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request)
    {
        $validData = $request->validate([
            'title' => 'required',
            'description' => 'required',
            'path' => 'required|mimes:mp4|max:100000',
            ]);
        $user = $request->user();
        $path = Storage::disk()->put($user->ID, $request->path);
        $validData['user_id'] = $request->user()->ID;
        $validData['path'] = $path;
        $result = new VideoResource(Video::create($validData));
        return response(['data'=>$result, 'message'=> 'Video is created']);
    }


    /**
     * Display the specified resource.
     *
     * @param  \App\Models\Video  $video
     * @return \Illuminate\Http\Response
     */
    public function show(Video $video)
    {
        //
        return response(['data'=>$video]);
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Models\Video  $video
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, Video $video)
    {
        $request->validate([
            'title' => 'required',
            'description' => 'required',
        ]);

        $result = $video->update($request->all());
        return response(['data'=>$result, 'message'=> 'Video is updated']);
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  \App\Models\Video  $video
     * @return \Illuminate\Http\Response
     */
    public function destroy(Request $request, Video $video)
    {
        if($video->user_id != $request->user()->id){
            return response()->json(['message' => 'You can only delete your own video.'], 403);
        }
        Storage::delete($video->path);
        $video->delete();
        
        return response(['message'=> 'Video is deleted']);
    }
}

More custom for handle error and response as json
Response when call GET /v1/video/

{
    "data": [
        {
            "id": 12,
            "user": {
                "id": 5,
                "email": "user1@test.com",
                "user_status": 0,
                "display_name": "Vuong anh duong"
            },
            "title": "test title",
            "description": "test description",
            "path": "http://localhost:8084/store/5/hnDd4kTE8qICp0RiZQ6pe5tEC1AJX8pKojrDq3GF.mp4"
        },
        {
            "id": 11,
            "user": {
                "id": 5,
                "email": "user1@test.com",
                "user_status": 0,
                "display_name": "Vuong anh duong"
            },
            "title": "test title",
            "description": "test description",
            "path": "http://localhost:8084/store/5/4qD7Hb9C35vHDToQXkj6FvW5WDa4aqTn321FtWKZ.mp4"
        }
        
    ]
}

It is optional because MiddleApi have passed Accept: application/json to all request then laravel will response as json
Add Trait for Rest api \app\Exceptions\Traits\RestExceptionHandlerTrait.php

<?php

namespace App\Exceptions\Traits;

use Exception;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

trait RestExceptionHandlerTrait
{

    /**
     * Creates a new JSON response based on exception type.
     *
     * @param Request $request
     * @param Exception $e
     * @return \Illuminate\Http\JsonResponse
     */
    protected function getJsonResponseForException(Request $request, Exception $e)
    {
        if($this->isModelNotFoundException($e))
            return $this->modelNotFound('Item not found');
        if($this->isNotFoundException($e))
            return $this->modelNotFound('Not found');
        
        return parent::render($request, $e);
    }

    /**
     * Returns json response for generic bad request.
     *
     * @param string $message
     * @param int $statusCode
     * @return \Illuminate\Http\JsonResponse
     */
    protected function badRequest($message = 'Bad request', $statusCode = 400)
    {
        return $this->jsonResponse(['error' => $message], $statusCode);
    }

    /**
     * Returns json response for Eloquent model not found exception.
     *
     * @param string $message
     * @param int $statusCode
     * @return \Illuminate\Http\JsonResponse
     */
    protected function modelNotFound($message = 'Record not found', $statusCode = 404)
    {
        return $this->jsonResponse(['error' => $message], $statusCode);
    }

    /**
     * Returns json response.
     *
     * @param array|null $payload
     * @param int $statusCode
     * @return \Illuminate\Http\JsonResponse
     */
    protected function jsonResponse(array $payload = null, $statusCode = 404)
    {
        $payload = $payload ?: [];

        return response()->json($payload, $statusCode);
    }

    /**
     * Determines if the given exception is an Eloquent model not found.
     *
     * @param Exception $e
     * @return bool
     */
    protected function isModelNotFoundException(Exception $e)
    {
        return $e instanceof ModelNotFoundException;
    }

    protected function isNotFoundException(Exception $e){
        return $e instanceof NotFoundHttpException;
    }

    protected function isApiCall(Request $request)
    {
        return strpos($request->getUri(), '/v1/') !== false;
    }
}

Change /app/Exception/Handler.php

<?php

namespace App\Exceptions;

use App\Exceptions\Traits\RestExceptionHandlerTrait;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;

class Handler extends ExceptionHandler
{
    use RestExceptionHandlerTrait;
    /**
     * A list of the exception types that are not reported.
     *
     * @var array
     */
    protected $dontReport = [
        //
    ];

    /**
     * A list of the inputs that are never flashed for validation exceptions.
     *
     * @var array
     */
    protected $dontFlash = [
        'password',
        'password_confirmation',
    ];

    /**
     * Register the exception handling callbacks for the application.
     *
     * @return void
     */
    public function register()
    {
        $this->reportable(function (Throwable $e) {
            //
        });
    }

    public function render($request, Throwable $e)
    {
        if(!$this->isApiCall($request)) {
            $retval = parent::render($request, $e);
        } else {
            $retval = $this->getJsonResponseForException($request, $e);
        }

        return $retval;
    }
}

Leave a Reply

Your email address will not be published. Required fields are marked *