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:

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.