Laravel 12 API Integration with Sanctum: Step-by-Step Guide

Installation & Setup Step 1: Install Laravel composer create-project laravel/laravel example-app This command creates a new Laravel project in the example-app directory. Step 2: Install Sanctum php artisan install:api This installs Laravel Sanctum for API authentication. Note: In Laravel 12, you might need to install Sanctum separately with: Step 3: Sanctum Configuration in migration file composer require laravel/sanctum php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" php artisan migrate Database Configuration Step 3: Migration Setup The provided migration file extends the default Laravel user table with additional fields for:

May 10, 2025 - 15:36
 0
Laravel 12 API Integration with Sanctum: Step-by-Step Guide

Installation & Setup

Step 1: Install Laravel

composer create-project laravel/laravel example-app

This command creates a new Laravel project in the example-app directory.

Step 2: Install Sanctum

php artisan install:api

This installs Laravel Sanctum for API authentication. Note: In Laravel 12, you might need to install Sanctum separately with:
Step 3: Sanctum Configuration in migration file

composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

Database Configuration

Step 3: Migration Setup

The provided migration file extends the default Laravel user table with additional fields for:

id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->string('otp')->nullable();
            $table->string('otp_created_at')->nullable();
            $table->boolean('is_otp_verified')->default(false);
            $table->timestamp('otp_expires_at')->nullable();
            $table->string('reset_password_token')->nullable();
            $table->timestamp('reset_password_token_expire_at')->nullable();
            $table->string('delete_token')->nullable();
            $table->timestamp('delete_token_expires_at')->nullable();
            $table->string('deleted_at')->nullable();
            $table->rememberToken();
            $table->timestamps();
        });

        Schema::create('password_reset_tokens', function (Blueprint $table) {
            $table->string('email')->primary();
            $table->string('token');
            $table->timestamp('created_at')->nullable();
        });

        Schema::create('sessions', function (Blueprint $table) {
            $table->string('id')->primary();
            $table->foreignId('user_id')->nullable()->index();
            $table->string('ip_address', 45)->nullable();
            $table->text('user_agent')->nullable();
            $table->longText('payload');
            $table->integer('last_activity')->index();
        });
    }

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

Model Configuration

Step 4: User Model (app/Models/User.php)

The User model is configured with:


     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'otp_expires_at' => 'datetime',
            'is_otp_verified' => 'boolean',
            'reset_password_token_expires_at' => 'datetime',
            'password' => 'hashed'
        ];
    }

}

API Routes

Step 5: Route Configuration (routes/api.php)

api.php

 'auth:sanctum'], static function () {
    Route::get('/refresh-token', [LoginController::class, 'refreshToken']);
    Route::post('/logout', [LogoutController::class, 'logout']);
});

Controller Implementation

RegisterController

Handles user registration and email verification:

validate([

            'name' => 'nullable|string|max:100',
            'email' => 'required|string|email|max:150|unique:users',
            'password' => 'required|string|min:8|confirmed',
        ]);
        try {
            $otp = random_int(1000, 9999);
            $otpExpiresAt = Carbon::now()->addMinutes(60);

            $user = User::create([

                'name' => $request->input('name'),
                'email' => $request->input('email'),
                'password' => Hash::make($request->input('password')),
                'otp' => $otp,
                'otp_expires_at' => $otpExpiresAt,
                'is_otp_verified' => false,
            ]);
            // Send OTP email
            Mail::to($user->email)->send(mailable: new OtpMail($otp, $user, 'Verify Your Email Address'));
            return response()->json([
                'status' => true,
                'message' => 'User successfully registered. Please verify your email to log in.',
                'code' => 201,
                'data' => $user
            ], 201);
        } catch (Exception $e) {
            return Helper::jsonErrorResponse('User registration failed', 500, [$e->getMessage()]);
        }
    }
    public function VerifyEmail(Request $request): \Illuminate\Http\JsonResponse
    {
        $request->validate([
            'email' => 'required|email|exists:users,email',
            'otp'   => 'required|digits:4',
        ]);
         try {
            $user = User::where('email', $request->input('email'))->first();

            // Check if email has already been verified
            if (!empty($user->email_verified_at)) {
                $user->is_verified = true;
                return Helper::jsonResponse(true, 'Email already verified.', 409);
            }

            if ((string)$user->otp !== (string)$request->input('otp')) {
                return Helper::jsonErrorResponse('Invalid OTP code', 422);
            }

            // Check if OTP has expired
            if (Carbon::parse($user->otp_expires_at)->isPast()) {
                return Helper::jsonErrorResponse('OTP has expired. Please request a new OTP.', 422);
            }
            $token = $user->createToken('YourAppName')->plainTextToken;
            // Verify the email
            $user->email_verified_at = now();
            $user->is_verified = true;
            $user->otp = null;
            $user->otp_expires_at = null;
            $user->save();
            return response()->json([
                'status' => true,
                'message' => 'Email verification successful.',
                'code' => 200,
                'token'      => $token,
                'data' => [
                    'id' => $user->id,
                    'email' => $user->email,
                    'name' => $user->name,
                    'is_verified' => $user->is_verified,
                ]
            ], 200);

         } catch (Exception $e) {
             return Helper::jsonErrorResponse($e->getMessage(), $e->getCode());
         }
    }

    public function ResendOtp(Request $request): \Illuminate\Http\JsonResponse
    {
        $request->validate([
            'email' => 'required|email|exists:users,email',
        ]);
        try {
            $user = User::where('email', $request->input('email'))->first();
            if (!$user) {
                return Helper::jsonErrorResponse('User not found.', 404);
            }

            if ($user->email_verified_at) {
                return Helper::jsonErrorResponse('Email already verified.', 409);
            }

            $newOtp               = random_int(1000, 9999);
            $otpExpiresAt         = Carbon::now()->addMinutes(60);
            $user->otp            = $newOtp;
            $user->otp_expires_at = $otpExpiresAt;
            $user->save();
            Mail::to($user->email)->send(new OtpMail($newOtp,$user,'Verify Your Email Address'));

            return Helper::jsonResponse(true, 'A new OTP has been sent to your email.', 200);
        } catch (Exception $e) {
            return Helper::jsonErrorResponse($e->getMessage(), $e->getCode());
        }
    }

}

LoginController


namespace App\Http\Controllers\API\Auth;

use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Models\User;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;

class LoginController extends Controller
{
public function Login(Request $request): \Illuminate\Http\JsonResponse
{
$request->validate([
'email' => 'required|string',
'password' => 'required|string',
]);
try {
if (filter_var($request->email, FILTER_VALIDATE_EMAIL) !== false) {
$user = User::withTrashed()->where('email', $request->email)->first();
if (empty($user)) {
return Helper::jsonErrorResponse('User not found', 404);
}
}
// Check the password
if (! Hash::check($request->password, $user->password)) {
return Helper::jsonErrorResponse('Invalid password', 401);
}

        // Check if the email is verified before login is successful
        if (! $user->email_verified_at) {
            return Helper::jsonErrorResponse('Email not verified. Please verify your email before logging in.', 403);
        }

        //* Generate token if email is verified
        $token = $user->createToken('YourAppName')->plainTextToken;

        return response()->json([
            'status'     => true,
            'message'    => 'User logged in successfully.',
            'code'       => 200,
            'token_type' => 'bearer',
            'token'      => $token,
            'data'       => [
                'id'          => $user->id,
                'name'        => $user->name,
                'email'       => $user->email,
                'is_verified' => $user->is_verified,
            ],
        ], 200);
    } catch (Exception $e) {
        return Helper::jsonErrorResponse($e->getMessage(), 500);

    }
}

public function refreshToken(): \Illuminate\Http\JsonResponse
{
    $refreshToken = auth('api')->refresh();

    return response()->json([
        'status'     => true,
        'message'    => 'Access token refreshed successfully.',
        'code'       => 200,
        'token_type' => 'bearer',
        'token'      => $refreshToken,
        'expires_in' => auth('api')->factory()->getTTL() * 60,
        'data'       => auth('api')->user()->load('personalizedSickle'),
    ]);
}     

}

ResetPasswordController
validate([
            'email' => 'required|email|exists:users,email'
        ]);

        try {
            $email = $request->input('email');
            $otp = random_int(1000, 9999);
            $user = User::where('email', $email)->first();

            if ($user) {
                Mail::to($email)->send(new OtpMail($otp, $user, 'Reset Your Password'));
                $user->update([
                    'otp' => $otp,
                    'otp_expires_at' => Carbon::now()->addMinutes(60),
                ]);
                return Helper::jsonResponse(true, 'OTP Code Sent Successfully Please Check Your Email.', 200);
            } else {
                return Helper::jsonErrorResponse('Invalid Email Address', 404);
            }
        } catch (Exception $e) {
            return Helper::jsonErrorResponse($e->getMessage(), 500);
        }
    }

    public function VerifyOTP(Request $request): \Illuminate\Http\JsonResponse
    {

        $request->validate([
            'email' => 'required|email|exists:users,email',
            'otp' => 'required|digits:4',
        ]);

        try {
            $email = $request->input('email');
            $otp = $request->input('otp');
            $user = User::where('email', $email)->first();

            if (!$user) {
                return Helper::jsonErrorResponse('User not found', 404);
            }

            if (Carbon::parse($user->otp_expires_at)->isPast()) {
                return Helper::jsonErrorResponse('OTP has expired.', 400);
            }

            if ($user->otp !== $otp) {
                return Helper::jsonErrorResponse('Invalid OTP', 400);
            }
            $token = Str::random(60);
            $user->update([
                'otp' => null,
                'otp_expires_at' => null,
                'reset_password_token' => $token,
                'reset_password_token_expire_at' => Carbon::now()->addHour(),
            ]);
            return response()->json([
                'status' => true,
                'message' => 'OTP verified successfully.',
                'code' => 200,
                'token' => $token,
            ]);
        } catch (Exception $e) {
            return Helper::jsonErrorResponse($e->getMessage(), 500);
        }
    }

    public function ResetPassword(Request $request): \Illuminate\Http\JsonResponse
    {
        $request->validate([
            'email' => 'required|email|exists:users,email',
            'token' => 'required|string',
            'password' => 'required|string|min:6|confirmed',

        ]);

        try {
            $email = $request->input('email');
            $newPassword = $request->input('password');

            $user = User::where('email', $email)->first();
            if (!$user) {
                return Helper::jsonErrorResponse('User not found', 404);
            }

            if (!empty($user->reset_password_token) && $user->reset_password_token === $request->token && $user->reset_password_token_expire_at >= Carbon::now()) {
                $user->update([
                    'password' => Hash::make($newPassword),
                    'reset_password_token' => null,
                    'reset_password_token_expire_at' => null,
                ]);

                return Helper::jsonResponse(true, 'Password reset successfully.', 200);
            } else {
                return Helper::jsonErrorResponse('Invalid Token', 419);
            }
        } catch (Exception $e) {
            return Helper::jsonErrorResponse($e->getMessage(), 500);
        }
    }
}

Helper Functions

The Helper class provides:

 $status,
            'message' => $message,
            'code'    => $code,
        ];
        if ($paginate && !empty($paginateData)) {
            $response['data'] = $data;
            $response['pagination'] = [
                'current_page' => $paginateData->currentPage(),
                'last_page' => $paginateData->lastPage(),
                'per_page' => $paginateData->perPage(),
                'total' => $paginateData->total(),
                'first_page_url' => $paginateData->url(1),
                'last_page_url' => $paginateData->url($paginateData->lastPage()),
                'next_page_url' => $paginateData->nextPageUrl(),
                'prev_page_url' => $paginateData->previousPageUrl(),
                'from' => $paginateData->firstItem(),
                'to' => $paginateData->lastItem(),
                'path' => $paginateData->path(),
            ];
        } elseif ($paginate && !empty($data)) {
            $response['data'] = $data->items();
            $response['pagination'] = [
                'current_page' => $data->currentPage(),
                'last_page' => $data->lastPage(),
                'per_page' => $data->perPage(),
                'total' => $data->total(),
                'first_page_url' => $data->url(1),
                'last_page_url' => $data->url($data->lastPage()),
                'next_page_url' => $data->nextPageUrl(),
                'prev_page_url' => $data->previousPageUrl(),
                'from' => $data->firstItem(),
                'to' => $data->lastItem(),
                'path' => $data->path(),
            ];
        } elseif ($data !== null) {
            $response['data'] = $data;
        }

        return response()->json($response, $code);
    }

    public static function jsonErrorResponse(string $message, int $code = 400, array $errors = []): JsonResponse
    {
        $response = [
            'status'  => false,
            'message' => $message,
            'code'    => $code,
            'errors'  => $errors,
        ];
        return response()->json($response, $code);
    }
}

Note if your need custom file upload function than just adding rhis code in Helper.php

 //! File or Image Upload
    public static function fileUpload($file, string $folder, string $name): ?string
    {
        if (!$file->isValid()) {
            return null;
        }

        $imageName = Str::slug($name) . '.' . $file->extension();
        $path      = public_path('uploads/' . $folder);
        if (!file_exists($path)) {
            if (!mkdir($path, 0755, true) && !is_dir($path)) {
                throw new \RuntimeException(sprintf('Directory "%s" was not created', $path));
            }
        }
        $file->move($path, $imageName);
        return 'uploads/' . $folder . '/' . $imageName;
    }

    //! File or Image Delete
    public static function fileDelete(string $path): void
    {
        if (file_exists($path)) {
            unlink($path);
        }
    }

Error Handling

The bootstrap/app.php is configured to:

withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        //
    })
     ->withExceptions(function (Exceptions $exceptions) {
        $exceptions->render(function (Throwable $e, Request $request) {
            if ($request->is('api/*')) {
                if ($e instanceof ValidationException) {
                    return Helper::jsonErrorResponse($e->getMessage(), 422, $e->errors());
                }
                if ($e instanceof ModelNotFoundException) {
                    return Helper::jsonErrorResponse($e->getMessage(), 404);
                }

                if ($e instanceof AuthenticationException) {
                    return Helper::jsonErrorResponse($e->getMessage(), 401);
                }
                if ($e instanceof AuthorizationException) {
                    return Helper::jsonErrorResponse($e->getMessage(), 403);
                }
                // Dynamically determine the status code if available
                $statusCode = method_exists($e, 'getStatusCode') ? $e->getStatusCode() : 500;
                return Helper::jsonErrorResponse($e->getMessage(), $statusCode);
            }
            return null;
        });
    })->create();

This implementation provides a solid foundation for a secure Laravel API with comprehensive authentication features.