Laravel API Tutorial for Beginners (Laravel 13 REST API) – MOUHIMEN.DEV
Laravel API Tutorial for Beginners (Laravel 13 REST API)

Laravel API Tutorial for Beginners (Laravel 13 REST API)

By Abd Almouhimen shamout | October 24, 2026

This Laravel API tutorial for beginners gives you the whole recipe first. Run php artisan install:api, make a model with a migration, add one Route::apiResource() line to get five endpoints, validate input with a form request, shape the JSON with an API resource, and lock it all with Sanctum’s auth:sanctum middleware. Send a request, and clean JSON comes back.

Watch the 9-minute video:

Below we build a real help desk tickets API, helpdesk-api, step by step in Laravel 13, with every command and every file, and then fix the four errors almost everyone hits.

What you need

  • PHP 8.3 or newer. Laravel 13 (released 17 March 2026) needs PHP 8.3+.
  • Composer and the Laravel installer (composer global require laravel/installer). On a fresh machine, php.new installs PHP, Composer and the installer in one command.
  • An API client such as Postman, Insomnia or Bruno, to send requests.
  • Basic PHP. You don’t need to know Laravel yet.

Still on Laravel 12? Every step below works the same way. Laravel 12 gets security fixes only until 24 February 2027, so start new projects on 13.

Step 1: Create the project and turn on API routes

laravel new helpdesk-api

The installer asks a few questions. For a pure API, pick None as the starter kit and keep SQLite as the database. SQLite needs zero setup: Laravel creates database/database.sqlite and runs the first migrations for you.

Here is the part beginners miss: a fresh Laravel app has no routes/api.php file. Turn API routing on with one command:

cd helpdesk-api
php artisan install:api

install:api does three things:

  1. Installs Laravel Sanctum, the token guard we use in Step 5.
  2. Creates routes/api.php.
  3. Publishes the migration for the personal_access_tokens table and offers to run it (say yes).

Every route in routes/api.php is stateless and gets the /api prefix automatically, so a tickets route lives at /api/tickets. Start the dev server:

php artisan serve
# INFO  Server running on [http://127.0.0.1:8000].

Step 2: Create the Ticket model and migration

php artisan make:model Ticket -m

The -m flag creates the migration in the same step. Open database/migrations/xxxx_create_tickets_table.php and add the columns a ticket needs:

Schema::create('tickets', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('description');
    $table->string('priority')->default('medium');
    $table->string('status')->default('open');
    $table->timestamps();
});

Run it:

php artisan migrate

Then open app/Models/Ticket.php and add a $fillable array:

class Ticket extends Model
{
    protected $fillable = [
        'title', 'description', 'priority', 'status',
    ];
}

$fillable is a safety list. It tells Laravel which fields Ticket::create() and update() may fill from a request, so nobody can sneak in a column you never meant to accept.

Step 3: Add the routes and the API controller

One Artisan command gives you the controller and two form requests:

php artisan make:controller TicketController --api --model=Ticket --requests
  • --api skips the create and edit methods (an API has no HTML forms).
  • --model=Ticket type-hints Ticket $ticket in the methods.
  • --requests creates StoreTicketRequest and UpdateTicketRequest.

Now add one line to routes/api.php:

use App\Http\Controllers\TicketController;
use Illuminate\Support\Facades\Route;

Route::apiResource('tickets', TicketController::class);

Check what you got:

php artisan route:list --path=api
MethodURIAction
GET/api/ticketsindex (list)
POST/api/ticketsstore (create)
GET/api/tickets/{ticket}show (one ticket)
PUT/PATCH/api/tickets/{ticket}update
DELETE/api/tickets/{ticket}destroy

Five endpoints from one line. show, update and destroy receive the ticket for free through route model binding: the 7 in /api/tickets/7 becomes Ticket #7, or a 404 if it doesn’t exist.

Step 4: Validate input and shape the JSON

Form requests

Open app/Http/Requests/StoreTicketRequest.php. Laravel generates authorize() returning false, which blocks every request with a 403. Return true (or a real permission check), then add the rules:

public function authorize(): bool
{
    return true;
}

public function rules(): array
{
    return [
        'title' => ['required', 'string', 'max:255'],
        'description' => ['required', 'string'],
        'priority' => ['sometimes', 'in:low,medium,high'],
    ];
}

In UpdateTicketRequest, add sometimes so a client can change just one field:

public function rules(): array
{
    return [
        'title' => ['sometimes', 'required', 'string', 'max:255'],
        'description' => ['sometimes', 'required', 'string'],
        'priority' => ['sometimes', 'in:low,medium,high'],
        'status' => ['sometimes', 'in:open,closed'],
    ];
}

Send an empty POST /api/tickets (with the Accept: application/json header) and Laravel answers 422 with a message and an errors list, without a single if in your code:

{
  "message": "The title field is required. (and 1 more error)",
  "errors": {
    "title": ["The title field is required."],
    "description": ["The description field is required."]
  }
}

API resources

Never send your model straight out (more on that below). Create a resource:

php artisan make:resource TicketResource
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'description' => $this->description,
        'priority' => $this->priority,
        'status' => $this->status,
        'opened_at' => $this->created_at->toDateTimeString(),
    ];
}

The finished controller

use App\Http\Requests\StoreTicketRequest;
use App\Http\Requests\UpdateTicketRequest;
use App\Http\Resources\TicketResource;
use App\Models\Ticket;

class TicketController extends Controller
{
    public function index()
    {
        return TicketResource::collection(Ticket::latest()->paginate(10));
    }

    public function store(StoreTicketRequest $request)
    {
        $ticket = Ticket::create($request->validated());

        return (new TicketResource($ticket))->response()->setStatusCode(201);
    }

    public function show(Ticket $ticket)
    {
        return new TicketResource($ticket);
    }

    public function update(UpdateTicketRequest $request, Ticket $ticket)
    {
        $ticket->update($request->validated());

        return new TicketResource($ticket);
    }

    public function destroy(Ticket $ticket)
    {
        $ticket->delete();

        return response()->noContent(); // 204
    }
}

Create a ticket:

POST /api/tickets
{
  "title": "VPN not connecting",
  "description": "Abd Almouhimen Shamout can't reach the office VPN from home.",
  "priority": "high"
}

You get 201 Created, with the ticket inside a data key. GET /api/tickets returns the list in data, plus links and meta from the paginator, so your React or Next.js front end knows how many pages there are.

Step 5: Protect the API with Sanctum

Right now anyone can delete your tickets. Add the HasApiTokens trait to app/Models/User.php (install:api reminds you about it in its output, and it’s easy to miss):

use Laravel\Sanctum\HasApiTokens;

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

Wrap the routes in the auth:sanctum middleware:

Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('tickets', TicketController::class);
});

A request with no token now gets 401 {"message": "Unauthenticated."}. To make a test token, open Tinker:

php artisan tinker
$user = User::factory()->create(['name' => 'Abd Almouhimen Shamout', 'email' => 'abd@mouhimen.dev']);
$user->createToken('postman')->plainTextToken;
// = "1|hN3kQ8vX2pLm9..."

Copy the token right away. Sanctum stores only a SHA-256 hash of it, so you can’t see the plain token again. In your API client, choose Authorization > Bearer Token, paste it, and send: 200 OK. In a real app, a login route hands out this token; that’s the next post in this series.

A tip from my client work

I never return an Eloquent model straight from a controller. Tables grow: one day someone adds an internal_notes column or a technician’s phone number, and a raw model sends it to every client the same day, without anyone noticing. A resource only sends the fields you list. Models stay inside, resources go out. And once outside apps use your API, put a version in the URL (/api/v1/tickets) so you can change things later without breaking them.

Errors and fixes

1. Route [login] not defined.
You called a protected route without a token, and because the request didn’t ask for JSON, Laravel tried to redirect you to a login page that an API doesn’t have. Send Accept: application/json on every request and you get a proper JSON 401 instead. In React or Next.js, set it once:

fetch('/api/tickets', {
  headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
});

To force JSON for every API route on the server side too, add a small middleware:

// app/Http/Middleware/ForceJson.php
public function handle(Request $request, Closure $next): Response
{
    $request->headers->set('Accept', 'application/json');

    return $next($request);
}

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->api(prepend: [\App\Http\Middleware\ForceJson::class]);
})

2. 403 This action is unauthorized.
The form request’s authorize() still returns false. Make it return true, or put your real permission check there.

3. Add [title] to fillable property to allow mass assignment on [App\Models\Ticket].
The model has no $fillable entry for that field. Add it, and create() works again.

4. 404 Not Found on /api/tickets.
Check three things: did you run php artisan install:api? Does your URL start with /api? Does php artisan route:list --path=api show the route? If you cached routes, run php artisan route:clear.

Checklist

  • [ ] php artisan install:api ran, and routes/api.php exists
  • [ ] Model has $fillable
  • [ ] Route::apiResource() registered, route:list shows 5 routes
  • [ ] Form requests return true from authorize() and have rules
  • [ ] Controller returns API resources, never raw models
  • [ ] HasApiTokens on User, routes inside auth:sanctum
  • [ ] Every client sends Accept: application/json

FAQ

Is Laravel good for building REST APIs?
Yes. Routing, validation, JSON resources, pagination and token auth are built in, so a beginner can ship a secure CRUD API in an afternoon, and it scales to large apps.

What’s the difference between Route::resource and Route::apiResource?
apiResource registers the same routes minus create and edit, which only show HTML forms. For an API you want apiResource.

Do I need Sanctum or Passport for a Laravel API?
Sanctum covers most apps: personal tokens for mobile and third-party clients, and cookie auth for your own SPA. Use Passport only when you need a full OAuth2 server.

Why does my Laravel API return HTML instead of JSON?
The request doesn’t send Accept: application/json, so Laravel answers like a browser request (redirects and HTML error pages). Add the header, or the ForceJson middleware above.

Does this tutorial work in Laravel 12?
Yes. install:api, apiResource, form requests, resources and Sanctum work the same in Laravel 12 and 13.

Keep learning

Watch the full video:

Sources

  • Laravel 13 docs, Routing (API routes, install:api, route:list): https://laravel.com/docs/13.x/routing
  • Laravel 13 docs, Controllers (--api, --model, --requests, apiResource): https://laravel.com/docs/13.x/controllers
  • Laravel 13 docs, Validation (form requests, 422 JSON errors, validated()): https://laravel.com/docs/13.x/validation
  • Laravel 13 docs, Eloquent API Resources (make:resource, data wrapping, pagination): https://laravel.com/docs/13.x/eloquent-resources
  • Laravel 13 docs, Sanctum (HasApiTokens, createToken, Bearer tokens): https://laravel.com/docs/13.x/sanctum
  • Laravel 13 docs, Installation (laravel new, SQLite default): https://laravel.com/docs/13.x/installation
  • Laravel News, Laravel 13 released (PHP 8.3 minimum, support dates): https://laravel-news.com/laravel-13-released

Abd Almouhimen Shamout

Full-Stack Web Developer with 16+ years of experience in designing, developing, and managing enterprise-level web systems and applications.
Skilled in both frontend and backend technologies with proven expertise in system integration, database management, and cloud services.
Successfully delivered solutions serving thousands of users in higher education and healthcare sectors. Recognized for strong problem-solving skills, leadership, and the ability to drive digital transformation projects.

Skills

javascript
androidstudio
drupal
python
wordpress
bootstrap
css3
livewire
tailwindcss
oracle
moodle
laravel