Bus Booking API with Laravel 12

Build a REST API backend for a bus booking system with auth and RBAC.

Introduction

30 Second Summary

Every time you book a bus ticket online, a backend API is handling your seat selection, verifying your login, and checking whether you have permission to manage routes or just browse them.

In this project, you will build a RESTful API backend for a bus booking system using Laravel 12. You will set up token-based authentication, role-based access control, and CRUD endpoints that manage bus routes and schedules.

What You'll Build

A fully functional REST API that authenticates users via tokens, enforces role-based permissions, and exposes endpoints for managing bus routes, schedules, and bookings.

By the end of this project, you'll have:

  • A Laravel 12 project connected to a MySQL database with migrations for users, bus routes, schedules, and bookings.
  • A working authentication API (register, login, logout) powered by Laravel Sanctum tokens.
  • A role-based access control system separating admin, operator, and passenger permissions on every endpoint.
  • Secret Mission: Add full-text search to bus routes using Laravel Scout with the database engine.

Are there any prerequisites?

This project assumes you already have PHP 8.2+, Composer, and MySQL installed on your machine. You will also need VS Code and an API testing tool like Postman.

Set Up the Project and Install Packages

Every API starts with a solid foundation. Before you can build authentication or route management, you need a properly configured Laravel project connected to a real database.

In this step, you will scaffold a fresh Laravel 12 project, connect it to MySQL, and install the two packages that power your API's auth and permissions.

In this step, get ready to:
  • Scaffold a Laravel 12 project and connect it to MySQL.
  • Install Laravel Sanctum for API token authentication.
  • Install Spatie Laravel Permission for role-based access control.
Create a new Laravel 12 project and configure MySQL

Laravel ships with SQLite by default, but your bus booking API needs MySQL for its relational data and full-text search capabilities.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold a new Laravel 12 project by running this command:
composer create-project laravel/laravel bus-booking-api

What does this command do?

Composer downloads the latest Laravel 12 framework and all its dependencies into a new folder called bus-booking-api. This gives you a fully working project skeleton with routing, configuration, and a default SQLite database already set up.

  • Confirm the project folder was created by running:
ls

You should see bus-booking-api in the output.

  • Move into the project folder by running:
cd bus-booking-api
  • Open the project in VS Code by running:
code .

code command not found?

If code . does not work, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter. Then click File, then Open Folder, and select the bus-booking-api folder on your Desktop.

Help me add VS Code to my PATH

Now you need to configure the database connection. Laravel defaults to SQLite, but you will switch it to MySQL.

  • In VS Code, open the .env file from the Explorer sidebar (it is in the project root).
  • Find the database section that starts with DB_CONNECTION=sqlite.
  • Replace the database variables with these values:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=bus_booking
DB_USERNAME=root
DB_PASSWORD=
  • Also add this line at the end of your .env file (you will use it in the Secret Mission later):
SCOUT_DRIVER=

What are these variables?

DB_CONNECTION tells Laravel which database driver to use. DB_HOST and DB_PORT point to your local MySQL server. DB_DATABASE is the name of the database you will create next.

DB_PASSWORD is left empty because most local MySQL installations use no password for the root user. If you set a password during MySQL installation, add it here.

  • Save .env.

Next, you need to create the actual MySQL database that Laravel will connect to.

  • Open a terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Log into the MySQL CLI by running:
mysql -u root -p

What will happen next?

MySQL will ask for your password. If you never set one, just press Enter to submit an empty password. You will see a mysql> prompt when you are connected.

  • Create the database by running this SQL command:
CREATE DATABASE bus_booking;
  • Exit the MySQL CLI by running:
exit;
  • Verify the database connection works by running Laravel's default migrations:
php artisan migrate

You should see output listing several migrations running successfully, including tables like users, sessions, and cache.

Seeing a connection refused error?

Make sure your MySQL server is running. On macOS, check System Settings then MySQL, or run brew services start mysql. On Windows, open Services and confirm the MySQL service is running.

Also double-check your .env values match exactly: DB_CONNECTION=mysql, DB_HOST=127.0.0.1, DB_PORT=3306.

Help me fix my database connection

Install Laravel Sanctum for API authentication

Sanctum is Laravel's lightweight package for issuing API tokens. Every request to your protected endpoints will include a Bearer token that Sanctum validates.

  • Install Sanctum and set up the API routing by running:
php artisan install:api

What prompts will appear?

The command will ask if you would like to run all pending database migrations. Type yes and press Enter. This creates the personal_access_tokens table that stores your API tokens.

The command also creates routes/api.php and registers it in your bootstrap/app.php file automatically.

Now you need to add the HasApiTokens trait to your User model so that users can generate tokens.

  • Open app/Models/User.php in VS Code.
  • Add this import at the top of the file, below the existing use statements:
use Laravel\Sanctum\HasApiTokens;
  • Find the line inside the class that says:
use HasFactory, Notifiable;
  • Replace it with:
use HasApiTokens, HasFactory, Notifiable;
  • Save app/Models/User.php.

Why HasApiTokens?

This trait gives the User model methods like createToken() and currentAccessToken(). Without it, Laravel would not know how to issue or revoke tokens for a user.

Install Spatie Laravel Permission for role management

Your API needs three distinct roles: admin, operator, and passenger. Spatie Laravel Permission provides a database-driven role and permission system that integrates with Laravel's middleware.

  • Install the package by running:
composer require spatie/laravel-permission
  • Publish the configuration file and migrations by running:
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
  • Clear the config cache to pick up the new package by running:
php artisan optimize:clear
  • Run the migrations to create the permission tables:
php artisan migrate

You should see new tables being created: roles, permissions, model_has_roles, model_has_permissions, and role_has_permissions.

Migration failed or config not found?

Make sure you ran the php artisan optimize:clear command before migrating. Laravel caches its configuration, and a stale cache will not include the new Spatie package.

Help me fix this migration error

Now add the HasRoles trait to the User model.

  • Switch back to app/Models/User.php.
  • Add this import at the top of the file, below the Sanctum import you just added:
use Spatie\Permission\Traits\HasRoles;
  • Find the trait line inside the class:
use HasApiTokens, HasFactory, Notifiable;
  • Replace it with:
use HasApiTokens, HasFactory, Notifiable, HasRoles;
  • Save app/Models/User.php.

Finally, register the Spatie middleware aliases so you can protect routes by role.

  • Open bootstrap/app.php.
  • Find the withMiddleware section. It currently looks like this:
->withMiddleware(function (Middleware $middleware) {
    //
})
  • Replace it with the following to register the role and permission middleware:
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
        'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
        'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
    ]);
})

What do these middleware aliases do?

Middleware aliases let you write role:admin in your route definitions instead of the full class path. You will use these in Step 4 to protect routes so that only users with the correct role can access them.

  • Save bootstrap/app.php.

Verify everything is wired up correctly by checking the registered routes.

  • Run this command to list all registered routes:
php artisan route:list

You should see API routes listed in the output. This confirms Sanctum, the API routes file, and your middleware are all registered correctly.

✔️ Awesome, I've got everything!

Great. Double check you have saved both app/Models/User.php and bootstrap/app.php.

ⓧ I'd like to double check the full code

Here are the key files as they should look at the end of this step.

.env (database section):

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=bus_booking
DB_USERNAME=root
DB_PASSWORD=
SCOUT_DRIVER=

app/Models/User.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable, HasRoles;

    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}

bootstrap/app.php:

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->alias([
            'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
            'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
            'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

Your project skeleton is ready with authentication and permission packages installed. Next up, you will design the database schema with Eloquent models for bus routes, schedules, and bookings.

Build Database Models and Migrations

Your Laravel project is connected to MySQL and ready for data. The next step is designing the database schema that stores bus routes, their schedules, and passenger bookings.

In Laravel, you define your database structure using migrations (which create the tables) and Eloquent models (which represent rows in those tables as PHP objects). Each model maps to a table and defines relationships to other models.

In this step, get ready to:
  • Create the BusRoute model and migration with an operator relationship.
  • Create the Schedule model and migration linked to bus routes.
  • Create the Booking model and migration linked to users and schedules.
Create the BusRoute model and migration

A bus route represents a journey between two locations. Each route belongs to an operator (a user with the operator role) and can have many schedules attached to it.

  • Generate the BusRoute model and its migration file by running this command in your terminal:
php artisan make:model BusRoute -m

What does this command do?

The -m flag tells Artisan to create both the model file (app/Models/BusRoute.php) and a timestamped migration file in database/migrations/ at the same time.

  • Open the newly created migration file in database/migrations/. It ends with _create_bus_routes_table.php.
  • Replace the contents of the up() and down() methods with the following schema definition:
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('bus_routes', function (Blueprint $table) {
            $table->id();
            $table->foreignId('operator_id')->constrained('users')->onDelete('cascade');
            $table->string('name');
            $table->string('origin');
            $table->string('destination');
            $table->decimal('distance_km', 8, 2)->nullable();
            $table->integer('duration_minutes')->nullable();
            $table->enum('status', ['active', 'inactive'])->default('active');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('bus_routes');
    }
};

What does this schema do?

This migration defines the bus_routes table. The operator_id column is a foreign key pointing to the users table. If a user is deleted, all their routes are removed too (onDelete('cascade')).

The distance_km and duration_minutes are nullable because operators might not know the exact values when first creating a route. The status enum defaults to active.

  • Save the migration file.
  • Now open app/Models/BusRoute.php and replace its contents with the following model definition:
<?php

namespace App\Models;

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

class BusRoute extends Model
{
    use HasFactory;

    protected $fillable = [
        'operator_id',
        'name',
        'origin',
        'destination',
        'distance_km',
        'duration_minutes',
        'status',
    ];

    public function operator()
    {
        return $this->belongsTo(User::class, 'operator_id');
    }

    public function schedules()
    {
        return $this->hasMany(Schedule::class);
    }
}

What do these relationships mean?

The $fillable array lists which columns can be mass-assigned (set all at once from a request). This protects against unwanted data being injected.

The operator() method defines a belongsTo relationship. It tells Laravel that each bus route is owned by one user (the operator). The schedules() method says a route can have many schedules.

  • Save app/Models/BusRoute.php.

Seeing a red underline on Schedule::class?

Your editor might flag Schedule::class because the Schedule model does not exist yet. This is expected. The error disappears once you create the Schedule model in the next substep.

help me understand this IDE warning about Schedule::class

✔️ Awesome, I've got everything!

Great. Make sure you have saved both your migration file and BusRoute.php before moving on.

ⓧ I'd like to double check the full code

Here is the full app/Models/BusRoute.php:

<?php

namespace App\Models;

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

class BusRoute extends Model
{
    use HasFactory;

    protected $fillable = [
        'operator_id',
        'name',
        'origin',
        'destination',
        'distance_km',
        'duration_minutes',
        'status',
    ];

    public function operator()
    {
        return $this->belongsTo(User::class, 'operator_id');
    }

    public function schedules()
    {
        return $this->hasMany(Schedule::class);
    }
}

And here is the full migration (_create_bus_routes_table.php):

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('bus_routes', function (Blueprint $table) {
            $table->id();
            $table->foreignId('operator_id')->constrained('users')->onDelete('cascade');
            $table->string('name');
            $table->string('origin');
            $table->string('destination');
            $table->decimal('distance_km', 8, 2)->nullable();
            $table->integer('duration_minutes')->nullable();
            $table->enum('status', ['active', 'inactive'])->default('active');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('bus_routes');
    }
};
Create the Schedule model and migration

A schedule represents a specific departure on a route. It stores the time, price, and seat availability. Each schedule belongs to one bus route and can have many bookings.

  • Generate the Schedule model and migration by running:
php artisan make:model Schedule -m
  • Open the new migration file in database/migrations/ ending with _create_schedules_table.php.
  • Replace its contents with the following schema:
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('schedules', function (Blueprint $table) {
            $table->id();
            $table->foreignId('bus_route_id')->constrained('bus_routes')->onDelete('cascade');
            $table->dateTime('departure_time');
            $table->dateTime('arrival_time');
            $table->decimal('price', 10, 2);
            $table->integer('available_seats');
            $table->integer('total_seats');
            $table->enum('status', ['active', 'inactive', 'completed'])->default('active');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('schedules');
    }
};

What does this schema do?

The bus_route_id foreign key links each schedule to a specific route. The price column uses decimal(10, 2) to store currency values with two decimal places.

The status enum includes a third option, completed, for schedules that have already departed.

  • Save the migration file.
  • Open app/Models/Schedule.php and replace its contents with:
<?php

namespace App\Models;

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

class Schedule extends Model
{
    use HasFactory;

    protected $fillable = [
        'bus_route_id',
        'departure_time',
        'arrival_time',
        'price',
        'available_seats',
        'total_seats',
        'status',
    ];

    protected function casts(): array
    {
        return [
            'departure_time' => 'datetime',
            'arrival_time' => 'datetime',
            'price' => 'decimal:2',
        ];
    }

    public function busRoute()
    {
        return $this->belongsTo(BusRoute::class);
    }

    public function bookings()
    {
        return $this->hasMany(Booking::class);
    }
}

What are casts?

The casts() method tells Laravel how to convert raw database values into PHP types. Casting departure_time and arrival_time to datetime gives you Carbon date objects you can format and compare easily.

Casting price to decimal:2 ensures it always returns a string with exactly two decimal places, which is important for displaying currency.

  • Save app/Models/Schedule.php.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both the migration and Schedule.php before moving on.

ⓧ I'd like to double check the full code

Here is the full app/Models/Schedule.php:

<?php

namespace App\Models;

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

class Schedule extends Model
{
    use HasFactory;

    protected $fillable = [
        'bus_route_id',
        'departure_time',
        'arrival_time',
        'price',
        'available_seats',
        'total_seats',
        'status',
    ];

    protected function casts(): array
    {
        return [
            'departure_time' => 'datetime',
            'arrival_time' => 'datetime',
            'price' => 'decimal:2',
        ];
    }

    public function busRoute()
    {
        return $this->belongsTo(BusRoute::class);
    }

    public function bookings()
    {
        return $this->hasMany(Booking::class);
    }
}

And here is the full migration (_create_schedules_table.php):

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('schedules', function (Blueprint $table) {
            $table->id();
            $table->foreignId('bus_route_id')->constrained('bus_routes')->onDelete('cascade');
            $table->dateTime('departure_time');
            $table->dateTime('arrival_time');
            $table->decimal('price', 10, 2);
            $table->integer('available_seats');
            $table->integer('total_seats');
            $table->enum('status', ['active', 'inactive', 'completed'])->default('active');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('schedules');
    }
};
Create the Booking model and migration

A booking connects a passenger (user) to a specific schedule. It tracks the seat number and whether the booking is pending, confirmed, or cancelled.

  • Generate the Booking model and migration by running:
php artisan make:model Booking -m
  • Open the new migration file in database/migrations/ ending with _create_bookings_table.php.
  • Replace its contents with the following schema:
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('bookings', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
            $table->foreignId('schedule_id')->constrained('schedules')->onDelete('cascade');
            $table->integer('seat_number');
            $table->enum('status', ['pending', 'confirmed', 'cancelled'])->default('pending');
            $table->dateTime('booked_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('bookings');
    }
};

What does this schema do?

This table has two foreign keys. The user_id connects the booking to the passenger. The schedule_id connects it to the specific departure they booked.

The booked_at timestamp is nullable because a booking might be created in a pending state before being confirmed with a timestamp.

  • Save the migration file.
  • Open app/Models/Booking.php and replace its contents with:
<?php

namespace App\Models;

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

class Booking extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'schedule_id',
        'seat_number',
        'status',
        'booked_at',
    ];

    protected function casts(): array
    {
        return [
            'booked_at' => 'datetime',
        ];
    }

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

    public function schedule()
    {
        return $this->belongsTo(Schedule::class);
    }
}

What do these relationships mean?

A booking belongsTo both a User and a Schedule. This lets you access $booking->user to get the passenger and $booking->schedule to get the departure details.

  • Save app/Models/Booking.php.

Now you need to add the inverse relationships to the User model so you can access a user's routes and bookings.

  • Open app/Models/User.php.
  • Find the closing } brace of the User class. Add these two relationship methods just above it:
    public function busRoutes()
    {
        return $this->hasMany(BusRoute::class, 'operator_id');
    }

    public function bookings()
    {
        return $this->hasMany(Booking::class);
    }

Why pass 'operator_id' to hasMany?

By default, Laravel guesses the foreign key is user_id. Since your bus_routes table uses operator_id instead, you explicitly tell Laravel which column links back to the User model.

  • Save app/Models/User.php.

All three models and their migrations are ready. Time to run the migrations and create the tables in your database.

  • Run the migrations to create all three tables by running:
php artisan migrate
  • Confirm all migrations ran successfully by running:
php artisan migrate:status
  • You should see Ran next to every migration in the list, including the new create_bus_routes_table, create_schedules_table, and create_bookings_table entries.

Migration failed with a foreign key error?

Foreign key constraints require that the referenced table already exists. Make sure your migration filenames have timestamps that order them correctly: bus_routes first, then schedules, then bookings. If they ran out of order, run php artisan migrate:rollback and rename the files so the timestamps are sequential.

help me fix a foreign key constraint error in my Laravel migrations

  • Verify the model relationships work by opening Tinker:
php artisan tinker
  • In the Tinker prompt, run this to check the BusRoute model loads its relationships:
App\Models\BusRoute::first();
  • You should see null returned (since there are no routes yet) without any errors. This confirms the model and table are correctly connected.
  • Type exit to leave Tinker.

✔️ Awesome, I've got everything!

Great. Make sure all model files and migrations are saved.

ⓧ I'd like to double check the full code

Here is the full app/Models/Booking.php:

<?php

namespace App\Models;

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

class Booking extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'schedule_id',
        'seat_number',
        'status',
        'booked_at',
    ];

    protected function casts(): array
    {
        return [
            'booked_at' => 'datetime',
        ];
    }

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

    public function schedule()
    {
        return $this->belongsTo(Schedule::class);
    }
}

Here is the full app/Models/User.php with the new relationships:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable, HasRoles;

    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function busRoutes()
    {
        return $this->hasMany(BusRoute::class, 'operator_id');
    }

    public function bookings()
    {
        return $this->hasMany(Booking::class);
    }
}

Here is the full bookings migration (_create_bookings_table.php):

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('bookings', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
            $table->foreignId('schedule_id')->constrained('schedules')->onDelete('cascade');
            $table->integer('seat_number');
            $table->enum('status', ['pending', 'confirmed', 'cancelled'])->default('pending');
            $table->dateTime('booked_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('bookings');
    }
};

Your database schema is in place with properly linked tables for routes, schedules, and bookings. Next up, you will build the authentication API so users can register, log in, and receive tokens to access protected endpoints.

Build the Authentication API

Your database is ready with all the tables a bus booking system needs. Now you need a way for users to prove who they are before they can book tickets or manage routes.

In this step, you will build the authentication API using Laravel Sanctum. Sanctum issues API tokens when users register or log in. These tokens then protect private endpoints from unauthorized access.

In this step, get ready to:
  • Create an AuthController with register and login methods.
  • Add logout and profile endpoints protected by Sanctum middleware.
  • Test the full authentication flow using Postman.
Create the AuthController with register and login

An API needs dedicated controller methods to handle incoming requests. The AuthController will manage all authentication logic in one place.

  • Generate the controller by running this command in your terminal:
php artisan make:controller Api/AuthController

What does this command do?

This creates a new file at app/Http/Controllers/Api/AuthController.php. The Api/ prefix tells Artisan to place the controller inside an Api subfolder, keeping your API controllers organized separately from web controllers.

  • Open app/Http/Controllers/Api/AuthController.php in VS Code.
  • Replace the entire file contents with the following code to add the register method:
<?php

namespace App\Http\Controllers\Api;

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

class AuthController extends Controller
{
    // Register a new user and return an API token
    public function register(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:users',
            'password' => 'required|string|min:8|confirmed',
        ]);

        // Create the user with a hashed password
        $user = User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'password' => Hash::make($validated['password']),
        ]);

        // Every new user starts as a passenger
        $user->assignRole('passenger');

        // Generate a Sanctum token for immediate API access
        $token = $user->createToken('auth-token')->plainTextToken;

        return response()->json([
            'user' => $user,
            'token' => $token,
        ], 201);
    }
}

What does this code do?

The register method handles new user sign-ups. It validates the incoming request data, creates a user record with a securely hashed password, assigns the default "passenger" role, and returns a Sanctum token.

The confirmed validation rule requires the request to include a password_confirmation field that matches password. This prevents typos during registration.

  • Save AuthController.php.

Now add the login method directly below the register method. Login checks existing credentials rather than creating a new account.

  • In AuthController.php, find the closing } of the register method. Add the following login method below it (but still inside the class):
    // Authenticate an existing user and return a token
    public function login(Request $request)
    {
        $request->validate([
            'email' => 'required|string|email',
            'password' => 'required|string',
        ]);

        // Find the user by email
        $user = User::where('email', $request->email)->first();

        // Check if user exists and password matches
        if (!$user || !Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['The provided credentials are incorrect.'],
            ]);
        }

        // Generate a fresh token for this login session
        $token = $user->createToken('auth-token')->plainTextToken;

        return response()->json([
            'user' => $user,
            'token' => $token,
        ]);
    }

What does this code do?

The login method looks up a user by email, then uses Hash::check to verify the plaintext password against the stored hash. If either check fails, it throws a validation exception with a generic error message.

Returning a generic "credentials are incorrect" message for both wrong-email and wrong-password is a security best practice. It prevents attackers from discovering which emails exist in your database.

  • Save AuthController.php.

Seeing a syntax error?

Make sure the login method is inside the AuthController class braces. You should have one closing } for the method and one final } for the class at the very end of the file.

Help me fix my AuthController syntax error

Add logout and user profile endpoints

Users need a way to invalidate their token when they are done. They also need an endpoint to check their own profile and assigned roles.

  • In AuthController.php, add the following two methods below the login method (still inside the class):
    // Revoke the current token to log out
    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();

        return response()->json([
            'message' => 'Logged out successfully',
        ]);
    }

    // Return the authenticated user and their roles
    public function profile(Request $request)
    {
        return response()->json([
            'user' => $request->user(),
            'roles' => $request->user()->getRoleNames(),
        ]);
    }

What does this code do?

The logout method calls currentAccessToken()->delete() to revoke the specific token used in the current request. Other tokens the user may have on different devices remain valid.

The profile method returns the authenticated user object along with their role names. This is useful for frontend apps that need to show role-specific UI elements.

  • Save AuthController.php.

Now you need to wire these controller methods to URL routes. Routes tell Laravel which controller method to call for each incoming request.

  • Open routes/api.php in VS Code.
  • Replace the entire file contents with the following route definitions:
<?php

use App\Http\Controllers\Api\AuthController;
use Illuminate\Support\Facades\Route;

// Public routes
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

// Protected routes
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/profile', [AuthController::class, 'profile']);
});

What does this code do?

The file defines two groups of routes. Public routes (/register and /login) are accessible to anyone. Protected routes (/logout and /profile) are wrapped in the auth:sanctum middleware.

Any request to a protected route without a valid Bearer token in the Authorization header will receive a 401 Unauthorized response automatically.

  • Save routes/api.php.

Getting a class not found error?

Double check that the use statement at the top of routes/api.php imports App\Http\Controllers\Api\AuthController with matching capitalization. The Api folder name is case-sensitive on Linux and macOS.

Help me debug my namespace issue

✔️ Awesome, I've got everything!

Great. Double check you have saved both AuthController.php and routes/api.php.

ⓧ I'd like to double check the full code

Here is the complete AuthController.php:

<?php

namespace App\Http\Controllers\Api;

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

class AuthController extends Controller
{
    public function register(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:users',
            'password' => 'required|string|min:8|confirmed',
        ]);

        $user = User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'password' => Hash::make($validated['password']),
        ]);

        $user->assignRole('passenger');

        $token = $user->createToken('auth-token')->plainTextToken;

        return response()->json([
            'user' => $user,
            'token' => $token,
        ], 201);
    }

    public function login(Request $request)
    {
        $request->validate([
            'email' => 'required|string|email',
            'password' => 'required|string',
        ]);

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

        if (!$user || !Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['The provided credentials are incorrect.'],
            ]);
        }

        $token = $user->createToken('auth-token')->plainTextToken;

        return response()->json([
            'user' => $user,
            'token' => $token,
        ]);
    }

    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();

        return response()->json([
            'message' => 'Logged out successfully',
        ]);
    }

    public function profile(Request $request)
    {
        return response()->json([
            'user' => $request->user(),
            'roles' => $request->user()->getRoleNames(),
        ]);
    }
}

And the complete routes/api.php:

<?php

use App\Http\Controllers\Api\AuthController;
use Illuminate\Support\Facades\Route;

// Public routes
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

// Protected routes
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/profile', [AuthController::class, 'profile']);
});
Test the authentication flow

The best way to confirm your API works is to send real HTTP requests against it. You will use Postman to simulate what a frontend app would do.

  • Start the Laravel development server by running this command:
php artisan serve

What does this command do?

This starts a local web server at http://127.0.0.1:8000. Keep this terminal running for the rest of the step. Open a new terminal tab if you need to run other commands.

  • Open Postman (or any API testing tool you prefer).
  • Create a new POST request to http://127.0.0.1:8000/api/register.
  • Set the request body type to raw and format to JSON.
  • Paste this JSON into the request body:
{
    "name": "Test User",
    "email": "test@example.com",
    "password": "password123",
    "password_confirmation": "password123"
}
  • Click Send.

You should see a 201 response containing a user object and a token string. Copy the token value for the next test.

  • Create a new GET request to http://127.0.0.1:8000/api/profile.
  • In the Authorization tab, select type Bearer Token.
  • Paste the token you copied from the register response.
  • Click Send.

You should see a 200 response with the user details and a roles array containing passenger.

  • Now test what happens without a token. Create a new GET request to http://127.0.0.1:8000/api/profile without any Authorization header.
  • Click Send.

You should see a 401 Unauthorized response. This confirms the auth:sanctum middleware is protecting your routes correctly.

  • Finally, test logout. Create a POST request to http://127.0.0.1:8000/api/logout with the Bearer token from earlier.
  • Click Send.

You should see a 200 response with the message "Logged out successfully". If you try the profile request again with the same token, it will now return 401.

Getting a 404 on the auth routes?

Make sure your URL includes the /api prefix. Laravel automatically prefixes routes defined in routes/api.php with /api.

Also confirm the dev server is still running in your terminal. If you see no output from php artisan serve, restart it.

Help me troubleshoot my 404 error

Why Sanctum over Passport?

Laravel Sanctum is designed for first-party SPAs and simple API token authentication. It is lightweight and ships as the default when you run php artisan install:api.

Laravel Passport is a full OAuth2 implementation meant for third-party API consumers. For a bus booking app where the React frontend is your own, Sanctum is the simpler and officially recommended choice.

Your authentication system is fully operational. Next up, you will define roles and permissions so different users get different levels of access to the API.

Set Up Role-Based Access Control

Your authentication system is working. Users can register, log in, and access protected routes with tokens.

But right now, every authenticated user has the same level of access. A passenger could theoretically delete bus routes or modify schedules. You need a way to separate what different types of users can do. In this step, you will seed roles and permissions into your database, then lock down your API routes so only the right roles can reach them.

In this step, get ready to:
  • Create a RoleSeeder that defines roles and permissions for admin, operator, and passenger users.
  • Update your API routes to enforce role-based middleware groups.
  • Verify roles are assigned correctly and middleware restricts access.
Create a RoleSeeder to define roles and permissions

A seeder is a class that populates your database with initial data. You need roles and permissions to exist in the database before you can assign them to users or check them in middleware.

  • Generate the RoleSeeder file by running this command in your terminal:
php artisan make:seeder RoleSeeder
  • You should see a confirmation message that the seeder was created successfully.
  • Open the new file at database/seeders/RoleSeeder.php in VS Code.
  • Replace the entire contents of the file with the following code:
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

class RoleSeeder extends Seeder
{
    public function run(): void
    {
        // Define the permissions for the bus booking system
        $permissions = [
            'manage-routes',
            'manage-schedules',
            'manage-bookings',
            'view-routes',
            'book-tickets',
        ];

        // Create each permission in the database
        foreach ($permissions as $permission) {
            Permission::firstOrCreate(['name' => $permission]);
        }

        // Admin gets all permissions
        $admin = Role::firstOrCreate(['name' => 'admin']);
        $admin->givePermissionTo(Permission::all());

        // Operator can manage routes and schedules
        $operator = Role::firstOrCreate(['name' => 'operator']);
        $operator->givePermissionTo(['manage-routes', 'manage-schedules', 'view-routes']);

        // Passenger can view routes and book tickets
        $passenger = Role::firstOrCreate(['name' => 'passenger']);
        $passenger->givePermissionTo(['view-routes', 'book-tickets']);
    }
}

What does this code do?

This seeder creates five permissions and three roles, then assigns specific permissions to each role.

  • firstOrCreate ensures the seeder is safe to run multiple times without creating duplicates.
  • The admin role receives every permission. The operator role can manage routes and schedules but cannot manage bookings. The passenger role can only view routes and book tickets.
  • Save database/seeders/RoleSeeder.php.
  • Run the seeder to populate your database by running this command:
php artisan db:seed --class=RoleSeeder
  • You should see a message confirming the seeder ran successfully.
  • Verify the roles were created by opening Tinker:
php artisan tinker
  • Inside Tinker, check that the roles exist by running:
Spatie\Permission\Models\Role::pluck('name');
  • You should see a collection containing admin, operator, and passenger.
  • Type exit to leave Tinker.

Seeder failed with a table not found error?

Make sure you ran php artisan migrate after publishing the Spatie permission migrations in the previous step. The seeder needs the roles and permissions tables to exist.

Why is my RoleSeeder failing with a missing table error?

Update routes with role-based middleware groups

With roles defined in the database, you can now lock down API endpoints so that only users with the correct role can access them. Spatie provides a role middleware that checks the authenticated user's role before allowing the request through.

What is role-based access control?

Role-based access control (RBAC) groups permissions under named roles, then assigns roles to users. Instead of checking individual permissions on every request, you check if the user has the right role.

In this API, admins can do everything. Operators can manage their routes and schedules. Passengers can only view routes and book tickets.

  • In VS Code, open routes/api.php.
  • Find the protected routes group that currently looks like this:
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/profile', [AuthController::class, 'profile']);
});
  • Replace the entire protected routes block with the following code to add role-based middleware groups:
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/profile', [AuthController::class, 'profile']);

    // Admin and Operator routes (placeholder - controllers added in next step)
    Route::middleware('role:admin|operator')->group(function () {
    });

    // Admin only routes (placeholder)
    Route::middleware('role:admin')->group(function () {
    });
});

What does this code do?

You are nesting middleware groups inside the auth:sanctum group. This means requests must first pass authentication, then pass the role check.

  • role:admin|operator allows users with either the admin or operator role through. This is where route and schedule management endpoints will live.
  • role:admin restricts access to admin users only. Destructive actions like deleting routes will go here.
  • The groups are empty placeholders for now. You will add the actual route definitions in the next step when you build the controllers.
  • Save routes/api.php.
  • Verify the route structure by running:
php artisan route:list
  • You should see your existing routes (/api/register, /api/login, /api/logout, /api/profile) listed with their middleware.

Seeing a class not found error?

If you see an error about a missing middleware class, double-check your bootstrap/app.php file. The role alias must point to Spatie\Permission\Middleware\RoleMiddleware::class. This was configured in the previous step.

Help me fix a middleware class not found error in my Laravel route setup

Verify role assignment with Tinker

The route groups are ready but empty. Before moving on, confirm that role assignment works end-to-end by creating a test admin user in Tinker.

  • Open Tinker by running:
php artisan tinker
  • Create a test user and assign the admin role by running these commands one at a time:
$user = App\Models\User::create(['name' => 'Admin User', 'email' => 'admin@test.com', 'password' => bcrypt('password123')]);
$user->assignRole('admin');
$user->getRoleNames();
  • You should see a collection containing admin returned from getRoleNames().
  • Type exit to leave Tinker.

Why test with Tinker?

Tinker gives you direct access to your application's models and database. By assigning a role in Tinker, you confirm that the HasRoles trait on your User model is correctly integrated with Spatie's permission tables.

When you build the controllers in the next step, the role:admin|operator middleware will automatically check this database-backed role before letting requests through.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Here is the full database/seeders/RoleSeeder.php:

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

class RoleSeeder extends Seeder
{
    public function run(): void
    {
        $permissions = [
            'manage-routes',
            'manage-schedules',
            'manage-bookings',
            'view-routes',
            'book-tickets',
        ];

        foreach ($permissions as $permission) {
            Permission::firstOrCreate(['name' => $permission]);
        }

        $admin = Role::firstOrCreate(['name' => 'admin']);
        $admin->givePermissionTo(Permission::all());

        $operator = Role::firstOrCreate(['name' => 'operator']);
        $operator->givePermissionTo(['manage-routes', 'manage-schedules', 'view-routes']);

        $passenger = Role::firstOrCreate(['name' => 'passenger']);
        $passenger->givePermissionTo(['view-routes', 'book-tickets']);
    }
}

Here is the full routes/api.php:

<?php

use App\Http\Controllers\Api\AuthController;
use Illuminate\Support\Facades\Route;

// Public routes
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

// Protected routes
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/profile', [AuthController::class, 'profile']);

    // Admin and Operator routes (placeholder - controllers added in next step)
    Route::middleware('role:admin|operator')->group(function () {
    });

    // Admin only routes (placeholder)
    Route::middleware('role:admin')->group(function () {
    });
});

Your access control layer is in place. Roles and permissions are seeded, and your route structure is ready to enforce them. Next up, you will build the Bus Route controllers that fill those empty middleware groups with real CRUD endpoints.

Build the Bus Routes and Schedules API

In the previous step, you locked down your API with role-based access control. Now it's time to build the endpoints that actually manage data.

A booking system is only useful if users can browse available routes and operators can publish new ones. In this step, you'll create controllers that expose CRUD operations for bus routes and schedules, protected by the roles you just configured.

In this step, get ready to:
  • Build the BusRouteController with list, create, view, update, and deactivate endpoints.
  • Register role-protected API routes for bus route management.
  • Create the ScheduleController for managing departure schedules on each route.
Create the BusRouteController

A resource controller in Laravel gives you pre-stubbed methods for standard CRUD operations. You'll replace those stubs with real logic that queries and persists bus route data.

  • Generate the resource controller by running this command in your terminal:
php artisan make:controller Api/BusRouteController --resource

What does this command do?

The --resource flag tells Artisan to generate a controller with stub methods for index, create, store, show, edit, update, and destroy. You'll replace these stubs with your own logic.

  • Open the newly created file at app/Http/Controllers/Api/BusRouteController.php in VS Code.
  • Replace the entire file contents with the following code that implements the index, store, and show methods:
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\BusRoute;
use Illuminate\Http\Request;

class BusRouteController extends Controller
{
    // Return all active bus routes with their operator info
    public function index()
    {
        $routes = BusRoute::where('status', 'active')
            ->with('operator:id,name')
            ->get();

        return response()->json($routes);
    }

    // Create a new bus route, assigning the authenticated user as operator
    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'origin' => 'required|string|max:255',
            'destination' => 'required|string|max:255',
            'distance_km' => 'nullable|numeric|min:0',
            'duration_minutes' => 'nullable|integer|min:0',
        ]);

        $validated['operator_id'] = $request->user()->id;
        $validated['status'] = 'active';

        $route = BusRoute::create($validated);

        return response()->json($route, 201);
    }

    // Return a single bus route with operator and schedules
    public function show(BusRoute $busRoute)
    {
        $busRoute->load(['operator:id,name', 'schedules']);

        return response()->json($busRoute);
    }
}

What does this code do?

The index() method filters for active routes and eager-loads the operator's id and name. This means public users can browse routes without seeing sensitive operator data.

The store() method validates the incoming request, then automatically sets operator_id to the authenticated user. This ties every route to the person who created it.

The show() method uses route model binding to find a bus route by its ID, then loads related schedules so a frontend can display them together.

  • Save BusRouteController.php.

Seeing a class not found error?

Make sure the namespace at the top of your file is App\Http\Controllers\Api (not App\Http\Controllers). The Api subfolder requires the matching namespace segment.

Also confirm that the use App\Models\BusRoute; import is present at the top of the file.

help me fix a class not found error in my BusRouteController

Implement update, delete, and add route protection

Your controller can list and create routes. Now you'll add the ability to update existing routes and deactivate them. Then you'll wire everything up in your API routes file with the correct role guards.

  • In BusRouteController.php, find the closing brace of the show() method.
  • Add the following update and destroy methods below it:
    // Validate and update a bus route
    public function update(Request $request, BusRoute $busRoute)
    {
        $validated = $request->validate([
            'name' => 'sometimes|string|max:255',
            'origin' => 'sometimes|string|max:255',
            'destination' => 'sometimes|string|max:255',
            'distance_km' => 'nullable|numeric|min:0',
            'duration_minutes' => 'nullable|integer|min:0',
            'status' => 'sometimes|in:active,inactive',
        ]);

        $busRoute->update($validated);

        return response()->json($busRoute);
    }

    // Deactivate a bus route instead of hard-deleting it
    public function destroy(BusRoute $busRoute)
    {
        $busRoute->update(['status' => 'inactive']);

        return response()->json([
            'message' => 'Bus route deactivated successfully',
        ]);
    }

What does this code do?

The update() method uses the sometimes validation rule, which means a field is only validated if it's present in the request. This allows partial updates where the client only sends the fields that changed.

The destroy() method performs a soft deactivation by setting the route's status to inactive. This preserves historical data for existing bookings instead of permanently deleting the record.

  • Save BusRouteController.php.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on to the routes.

ⓧ I'd like to double check the full code

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\BusRoute;
use Illuminate\Http\Request;

class BusRouteController extends Controller
{
    public function index()
    {
        $routes = BusRoute::where('status', 'active')
            ->with('operator:id,name')
            ->get();

        return response()->json($routes);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'origin' => 'required|string|max:255',
            'destination' => 'required|string|max:255',
            'distance_km' => 'nullable|numeric|min:0',
            'duration_minutes' => 'nullable|integer|min:0',
        ]);

        $validated['operator_id'] = $request->user()->id;
        $validated['status'] = 'active';

        $route = BusRoute::create($validated);

        return response()->json($route, 201);
    }

    public function show(BusRoute $busRoute)
    {
        $busRoute->load(['operator:id,name', 'schedules']);

        return response()->json($busRoute);
    }

    public function update(Request $request, BusRoute $busRoute)
    {
        $validated = $request->validate([
            'name' => 'sometimes|string|max:255',
            'origin' => 'sometimes|string|max:255',
            'destination' => 'sometimes|string|max:255',
            'distance_km' => 'nullable|numeric|min:0',
            'duration_minutes' => 'nullable|integer|min:0',
            'status' => 'sometimes|in:active,inactive',
        ]);

        $busRoute->update($validated);

        return response()->json($busRoute);
    }

    public function destroy(BusRoute $busRoute)
    {
        $busRoute->update(['status' => 'inactive']);

        return response()->json([
            'message' => 'Bus route deactivated successfully',
        ]);
    }
}

Now you need to register your API routes so Laravel knows which URLs map to which controller methods, and which roles are allowed to access each one.

  • Open routes/api.php in VS Code.
  • Replace the entire file contents with the following code to register bus route endpoints with role protection:
<?php

use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\BusRouteController;
use Illuminate\Support\Facades\Route;

// Public routes
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

// Public read-only routes
Route::get('/bus-routes', [BusRouteController::class, 'index']);
Route::get('/bus-routes/{busRoute}', [BusRouteController::class, 'show']);

// Protected routes
Route::middleware('auth:sanctum')->group(function () {
    // Auth
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/profile', [AuthController::class, 'profile']);

    // Admin and Operator routes
    Route::middleware('role:admin|operator')->group(function () {
        Route::post('/bus-routes', [BusRouteController::class, 'store']);
        Route::put('/bus-routes/{busRoute}', [BusRouteController::class, 'update']);
    });

    // Admin only routes
    Route::middleware('role:admin')->group(function () {
        Route::delete('/bus-routes/{busRoute}', [BusRouteController::class, 'destroy']);
    });
});

How are these routes protected?

The GET routes for listing and viewing bus routes live outside any middleware group. Anyone can browse available routes without logging in.

Creating and updating routes requires auth:sanctum (a valid token) plus the role:admin|operator middleware. Only admins and operators can modify route data.

Deleting (deactivating) a route is restricted to the admin role only, providing an extra layer of control over destructive actions.

  • Save routes/api.php.
  • Verify the routes registered correctly by running:
php artisan route:list

You should see your new GET, POST, PUT, and DELETE routes for /api/bus-routes listed in the output.

Routes not showing up?

Make sure the use App\Http\Controllers\Api\BusRouteController; import is at the top of your routes/api.php file. A missing import will cause a class-not-found error.

Also check that bootstrap/app.php still includes the api: line in withRouting(). Without it, Laravel won't load your API routes file.

help me fix my Laravel API routes not appearing in route:list

Create the ScheduleController

Bus routes on their own are just origin-destination pairs. Schedules add the time, price, and seat capacity that passengers actually care about when booking.

  • Generate the ScheduleController by running:
php artisan make:controller Api/ScheduleController
  • Open app/Http/Controllers/Api/ScheduleController.php in VS Code.
  • Replace the entire file contents with the following code:
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\BusRoute;
use App\Models\Schedule;
use Illuminate\Http\Request;

class ScheduleController extends Controller
{
    // List active schedules for a specific bus route
    public function index(BusRoute $busRoute)
    {
        $schedules = $busRoute->schedules()
            ->where('status', 'active')
            ->get();

        return response()->json($schedules);
    }

    // Create a new schedule for a bus route
    public function store(Request $request, BusRoute $busRoute)
    {
        $validated = $request->validate([
            'departure_time' => 'required|date|after:now',
            'arrival_time' => 'required|date|after:departure_time',
            'price' => 'required|numeric|min:0',
            'total_seats' => 'required|integer|min:1',
        ]);

        $validated['bus_route_id'] = $busRoute->id;
        $validated['available_seats'] = $validated['total_seats'];
        $validated['status'] = 'active';

        $schedule = Schedule::create($validated);

        return response()->json($schedule, 201);
    }
}

What does this code do?

The index() method uses route model binding on $busRoute to automatically find the parent route, then queries its related schedules filtered to active ones only.

The store() method validates departure and arrival times (arrival must be after departure), then sets available_seats equal to total_seats. As passengers book tickets, available_seats will decrease.

  • Save ScheduleController.php.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before registering its routes.

ⓧ I'd like to double check the full code

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\BusRoute;
use App\Models\Schedule;
use Illuminate\Http\Request;

class ScheduleController extends Controller
{
    public function index(BusRoute $busRoute)
    {
        $schedules = $busRoute->schedules()
            ->where('status', 'active')
            ->get();

        return response()->json($schedules);
    }

    public function store(Request $request, BusRoute $busRoute)
    {
        $validated = $request->validate([
            'departure_time' => 'required|date|after:now',
            'arrival_time' => 'required|date|after:departure_time',
            'price' => 'required|numeric|min:0',
            'total_seats' => 'required|integer|min:1',
        ]);

        $validated['bus_route_id'] = $busRoute->id;
        $validated['available_seats'] = $validated['total_seats'];
        $validated['status'] = 'active';

        $schedule = Schedule::create($validated);

        return response()->json($schedule, 201);
    }
}

Now register the schedule routes in your API routes file so they're accessible as nested endpoints under bus routes.

  • Open routes/api.php again.
  • Replace the entire file contents with the updated version that includes the ScheduleController import and schedule routes:
<?php

use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\BusRouteController;
use App\Http\Controllers\Api\ScheduleController;
use Illuminate\Support\Facades\Route;

// Public routes
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

// Public read-only routes
Route::get('/bus-routes', [BusRouteController::class, 'index']);
Route::get('/bus-routes/{busRoute}', [BusRouteController::class, 'show']);
Route::get('/bus-routes/{busRoute}/schedules', [ScheduleController::class, 'index']);

// Protected routes
Route::middleware('auth:sanctum')->group(function () {
    // Auth
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/profile', [AuthController::class, 'profile']);

    // Admin and Operator routes
    Route::middleware('role:admin|operator')->group(function () {
        Route::post('/bus-routes', [BusRouteController::class, 'store']);
        Route::put('/bus-routes/{busRoute}', [BusRouteController::class, 'update']);
        Route::post('/bus-routes/{busRoute}/schedules', [ScheduleController::class, 'store']);
    });

    // Admin only routes
    Route::middleware('role:admin')->group(function () {
        Route::delete('/bus-routes/{busRoute}', [BusRouteController::class, 'destroy']);
    });
});

Why are schedules nested under bus routes?

The URL pattern /bus-routes/{busRoute}/schedules makes the parent-child relationship explicit. Laravel automatically resolves {busRoute} via route model binding, so the controller always receives the correct parent route instance.

Viewing schedules is public (GET), but creating schedules requires the admin|operator role. Passengers can browse but not modify schedule data.

  • Save routes/api.php.
  • Confirm everything registered by running:
php artisan route:list

You should now see schedule routes (GET /api/bus-routes/{busRoute}/schedules and POST /api/bus-routes/{busRoute}/schedules) alongside all your earlier routes.

✔️ Awesome, I've got everything!

Great. Your routes file is complete for this step.

ⓧ I'd like to double check the full code

<?php

use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\BusRouteController;
use App\Http\Controllers\Api\ScheduleController;
use Illuminate\Support\Facades\Route;

// Public routes
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

// Public read-only routes
Route::get('/bus-routes', [BusRouteController::class, 'index']);
Route::get('/bus-routes/{busRoute}', [BusRouteController::class, 'show']);
Route::get('/bus-routes/{busRoute}/schedules', [ScheduleController::class, 'index']);

// Protected routes
Route::middleware('auth:sanctum')->group(function () {
    // Auth
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/profile', [AuthController::class, 'profile']);

    // Admin and Operator routes
    Route::middleware('role:admin|operator')->group(function () {
        Route::post('/bus-routes', [BusRouteController::class, 'store']);
        Route::put('/bus-routes/{busRoute}', [BusRouteController::class, 'update']);
        Route::post('/bus-routes/{busRoute}/schedules', [ScheduleController::class, 'store']);
    });

    // Admin only routes
    Route::middleware('role:admin')->group(function () {
        Route::delete('/bus-routes/{busRoute}', [BusRouteController::class, 'destroy']);
    });
});

Now test the full flow to verify role-based access works as expected. You'll need an operator user to create routes, then confirm that unauthenticated users can view them and passengers cannot modify them.

  • Make sure your development server is still running. If not, start it with:
php artisan serve
  • Open Postman (or any API testing tool).
  • Create an operator user by running this in a new terminal tab:
php artisan tinker
  • In the Tinker session, create a user and assign the operator role:
$user = \App\Models\User::factory()->create(['name' => 'Test Operator', 'email' => 'operator@test.com', 'password' => bcrypt('password123')]);
$user->assignRole('operator');
exit;
  • In Postman, send a POST request to http://127.0.0.1:8000/api/login with the JSON body:
{
    "email": "operator@test.com",
    "password": "password123"
}
  • Copy the token value from the response.
  • Send a POST request to http://127.0.0.1:8000/api/bus-routes with the Authorization: Bearer <your-token> header and this JSON body:
{
    "name": "City Express",
    "origin": "Downtown Station",
    "destination": "Airport Terminal",
    "distance_km": 45.5,
    "duration_minutes": 60
}

You should receive a 201 response with the newly created bus route, including an auto-assigned operator_id.

  • Test the public endpoint by sending a GET request to http://127.0.0.1:8000/api/bus-routes without any Authorization header.

You should see the route you just created in the response, proving that unauthenticated users can browse available routes.

  • Test role enforcement by logging in as a passenger (register a new user via the /api/register endpoint) and trying to POST to /api/bus-routes.

You should receive a 403 Forbidden response, confirming that passengers cannot create or modify routes.

Getting a 500 error instead of 403?

If you see an internal server error when testing role middleware, clear the permission cache by running php artisan optimize:clear in your terminal. Spatie Permission caches roles aggressively.

Also confirm your bootstrap/app.php has the role middleware alias registered. Without it, Laravel won't recognize the role:admin|operator middleware string.

help me debug a 500 error when testing role-based access on my bus routes API

Your API now has full CRUD for bus routes and schedules, all protected by role-based middleware. Next up, you'll extend this with a search feature in the Secret Mission.

Secret mission

Add Full-Text Search with Laravel Scout

Your API lets users list bus routes, but what if they want to search by city name or route name? Add full-text search to your bus routes endpoint using Laravel Scout with the database engine. No external search service required.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • A MySQL database (bus_booking) running locally.
  • A Laravel development server (only runs while php artisan serve is active).
  • The bus-booking-api project folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or plan to use this API as the backend for the React frontend in Project 2.

  • Leave the bus_booking database and project folder as-is.
  • The development server only runs while you have php artisan serve active in your terminal. It stops automatically when you close the terminal window.

Pause - I'll come back to this later

Shut down the running process to free up your terminal. Your database and project files persist until you manually remove them.

  • Stop the Laravel development server by pressing Ctrl+C in the terminal where it's running.
  • The bus_booking MySQL database persists on your machine until you manually drop it.
  • When you're ready to resume, navigate back to the bus-booking-api folder and run php artisan serve again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your database and all project files.

  • Stop the Laravel development server by pressing Ctrl+C if it's still running.
  • Drop the MySQL database by running:
mysql -u root -p -e "DROP DATABASE bus_booking;"
  • Delete the project folder. On macOS/Linux:
cd ~/Desktop
rm -rf bus-booking-api

On Windows:

cd ~\Desktop
rmdir /s /q bus-booking-api
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see bus-booking-api in the listing.

Nice Work!

Nice Work!

You just built a production-style REST API backend from scratch using Laravel 12 and MySQL. This API is ready to serve as the foundation for a React frontend in a follow-up project.

You've learned how to:

  • Design a relational database schema with Eloquent models and migrations for bus routes, schedules, and bookings.
  • Implement token-based authentication (register, login, logout) using Laravel Sanctum.
  • Set up role-based access control with three roles (admin, operator, passenger) using Spatie Laravel Permission, and built CRUD API endpoints for bus routes and schedules with proper role guards.
  • Secret Mission: Added full-text search to bus routes using Laravel Scout with the database engine.

Ready to quiz yourself?