All articles
LaravelREST APIPHPBackend DevelopmentAPI Design

Building REST APIs with Laravel: What I Wish I Knew in Year One

PKParth Kanasagra
9 min read

In my first year writing Laravel APIs I was returning raw Eloquent models directly from controllers. return response()->json($users) — done. It worked. It also exposed database columns I shouldn't have, broke mobile apps when I renamed a field, and made testing a nightmare.

Nine years and probably a hundred different APIs later, here's how I actually do it now.

Always Use API Resources. Always.

This is the rule I wish someone had forced on me from day one. API Resources decouple your database schema from your API contract. You can rename a column in your DB without breaking your API consumers.

// Never do this
return response()->json($post);

// Always do this
return new PostResource($post);

A Resource also lets you shape the output intentionally:

// app/Http/Resources/PostResource.php
public function toArray(Request $request): array
{
    return [
        'id'           => $this->id,
        'title'        => $this->title,
        'published_at' => $this->published_at?->toIso8601String(),
        'author'       => new UserResource($this->whenLoaded('author')),
        'tags'         => $this->tags ?? [],
    ];
}

whenLoaded() is underrated. If the relationship wasn't eager loaded, it won't appear in the response instead of triggering a query. Clean.

Sanctum for Auth (and Why Not Passport)

For most API projects, Sanctum is the right call. Passport is OAuth2 — it's powerful, but the overhead is real. Unless you're building a platform where third-party apps need to authenticate as your users, Sanctum handles it perfectly:

// Issue a token
$token = $user->createToken('mobile-app', ['posts:read', 'posts:write']);
return ['token' => $token->plainTextToken];

// Protecting routes
Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('posts', PostController::class);
});

The ability scopes on tokens are a nice touch that most people miss. They let you issue tokens with limited permissions — great for API keys on third-party integrations.

Version Your API from Day One

I've seen teams try to add versioning after three years of a live API. It's painful. Just do it from the start — it costs you nothing early on and saves you enormously later:

// routes/api_v1.php — registered in RouteServiceProvider
Route::prefix('v1')->name('api.v1.')->group(function () {
    Route::apiResource('posts', Api\V1\PostController::class);
});

// When v2 needs breaking changes:
Route::prefix('v2')->name('api.v2.')->group(function () {
    Route::apiResource('posts', Api\V2\PostController::class);
});

Consistent Error Responses

Nothing is more frustrating as a mobile developer than an API that returns errors in five different formats. Lock it down in the exception handler:

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (ValidationException $e) {
        return response()->json([
            'message' => 'The given data was invalid.',
            'errors'  => $e->errors(),
        ], 422);
    });

    $exceptions->render(function (ModelNotFoundException $e) {
        return response()->json(['message' => 'Resource not found.'], 404);
    });
})

Rate Limiting That Actually Works

The default throttle:60,1 is a starting point. In practice, you want different limits for different user types:

RateLimiter::for('api', function (Request $request) {
    $user = $request->user();

    return match(true) {
        $user?->isPremium() => Limit::none(),
        $user !== null      => Limit::perMinute(120)->by($user->id),
        default             => Limit::perMinute(20)->by($request->ip()),
    };
});

The N+1 Problem Will Catch You in Production

If you're returning a paginated list of posts with authors and you haven't eager loaded the author, you're making N+1 queries. With 15 posts per page that's 16 queries instead of 2. With 50 posts it's catastrophic.

// This causes N+1 — avoid
return PostResource::collection(Post::paginate(15));

// This is correct — 2 queries regardless of page size
return PostResource::collection(
    Post::with('author', 'tags')->published()->cursorPaginate(15)
);

Run Model::preventLazyLoading() in your AppServiceProvider when app()->isLocal(). It will throw exceptions in development the moment you accidentally trigger a lazy load. I've caught more bugs with this than with any other single tool.

Testing Is Not Optional

Every endpoint needs a test. I know it's slow at first. It's much slower to debug why the mobile app is returning a 500 in production on a Friday afternoon.

it('returns paginated posts for authenticated users', function () {
    $user = User::factory()->create();
    Post::factory(20)->published()->create();

    $this->actingAs($user)
         ->getJson('/api/v1/posts')
         ->assertOk()
         ->assertJsonStructure(['data', 'meta', 'links'])
         ->assertJsonCount(15, 'data');
});

Things I'd Tell My Year-One Self

Use API Resources from the start. Version your API before you have consumers. Write tests before they're painful to retrofit. Rate limit every public endpoint. And never, ever return raw Eloquent models.

Related Articles

PK

Parth Kanasagra

Certified Senior Laravel & PHP Developer · 9+ years experience · Available for hire