Skip to content
Back to Blog

Stop Building Fragile APIs: A Laravel Engineering Blueprint

Bashar AyyashDecember 7, 2025Updated May 27, 20268 min read1,406 words
Stop Building Fragile APIs: A Laravel Engineering Blueprint
TL;DR

An engineering blueprint for building robust Laravel APIs, focusing on Sanctum, Form Requests, and API Resources.

8 min read · 1,406 words

Stop Building Fragile APIs: A Laravel Engineering Blueprint

I've watched a $4 million treasury payment fail because an API leaked an internal is_flagged_for_review field to a trading partner. The partner's system parsed the raw JSON, saw the flag, and auto-rejected the settlement. Twenty-two minutes of downtime. Two engineering directors on a conference call at 2 AM. All because someone returned User::all() directly from a controller and called it a day.

In twenty years of building systems for banks, payment processors, and trading platforms, I've learned that a "working" API isn't the same as a production-ready API. Laravel makes it dangerously easy to spin up endpoints. It also makes it easy to build something that collapses the moment it touches real traffic, real users, and real adversaries.

Here's how I build APIs that survive.

The Danger of Raw Model Returns

If your controller looks like this, you're holding a loaded weapon:

public function show(User $user)
{
    return $user;
}

Laravel will serialize the entire model. Every fillable, every hidden, every relationship—regardless of your $hidden array. Mass assignment protection doesn't apply to serialization. Neither does authorization. I've seen APIs leak hashed passwords, internal account statuses, soft-delete timestamps, and foreign keys to tables that should never be public.

Circular references are another grenade. Eager-load a User with their Posts, each post eager-loaded with the User, and PHP runs out of memory or JSON encoding recursion limits. In high-frequency trading interfaces, I've seen this take down worker nodes during market open.

Stop returning models. Always transform.

API Resources: Your Contract with the Client

Laravel API Resources are not decoration. They are your enforced contract. They guarantee that the client receives exactly what you promise—nothing more, nothing less.

Here's what I refuse to let into production:

// Before: Fragile, leaking internals
public function index()
{
    return User::with('accounts')->get();
}

And here's what ships:

// After: Explicit, versioned, safe
class UserResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->uuid,
            'full_name' => $this->full_name,
            'email' => $this->when($this->isTheAuthenticatedUser(), $this->email),
            'accounts' => AccountResource::collection($this->whenLoaded('accounts')),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

Note the details: UUIDs instead of auto-incrementing IDs (harder to enumerate), conditional fields based on authorization, ISO 8601 timestamps (no ambiguous formatting), and whenLoaded to prevent accidental N+1 queries. In fintech, this isn't pedantry. It's regulatory hygiene.

Enforce a Consistent Response Envelope

Your API should speak one language. A client shouldn't parse three different error shapes across endpoints.

I enforce this envelope everywhere:

// Success
{
    "data": { ... },
    "meta": {
        "timestamp": "2026-05-27T14:32:00Z"
    }
}

// Error
{
    "error": {
        "code": "INSUFFICIENT_FUNDS",
        "message": "The account balance is too low for this transaction."
    },
    "meta": {
        "timestamp": "2026-05-27T14:32:00Z"
    }
}

I keep a base ApiResponse helper or a custom middleware to wrap everything. HTTP status codes matter—200 for success, 422 for validation failure, 409 for business rule conflicts—but the body shape stays constant. When you're integrating with core banking systems that retry on specific HTTP codes, inconsistency costs real money.

Rate Limiting Is Not Optional

Raw Laravel throttle middleware is fine for blogs. For production APIs, I configure per-endpoint, per-user-tier limits.

Route::middleware(['auth:sanctum', 'throttle:transactions'])->group(function () {
    Route::post('/payments', [PaymentController::class, 'store']);
});

Then in AppServiceProvider:

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

    return $user->tier === 'enterprise'
        ? Limit::perMinute(1000)->by($user->id)
        : Limit::perMinute(10)->by($user->id);
});

Payment endpoints get stricter, lower limits than read-only balance checks. Internal service accounts get whitelisted by IP. I log every throttle hit—if a client is hammering us, that's either a bug or an attack. In either case, I want to know before the fraud team calls.

Pagination: Cursor vs. Offset

Offset pagination is the default. It's also a performance cliff.

User::paginate(50); // LIMIT 50 OFFSET 1450

With large datasets,OFFSET forces the database to scan and discard rows. In a payments ledger with 40 million rows, page 3,000 becomes a 4-second query.

For user-facing, real-time feeds—infinite scroll, activity streams—I use cursor pagination:

User::orderBy('id')->cursorPaginate(50);

This translates to a WHERE id > ? LIMIT 50 query. Constant time. No drift if new records are inserted during pagination.

I use offset pagination only for admin dashboards and reporting exports where users need to jump to specific pages. Everything else gets cursors.

Filtering, Sorting, and Sparse Fieldsets

Don't build bespoke query parameters for every endpoint. I use a disciplined, predictable pattern:

GET /api/transactions?filter[status]=settled&filter[amount_gte]=1000&sort=-created_at&fields[transaction]=id,amount,status

In the controller:

public function index(Request $request)
{
    $query = Transaction::query()
        ->when($request->input('filter.status'), fn ($q, $status) => $q->where('status', $status))
        ->when($request->input('sort'), fn ($q, $sort) => $q->orderBy(ltrim($sort, '-'), str_starts_with($sort, '-') ? 'desc' : 'asc'));

    return TransactionResource::collection($query->cursorPaginate(50));
}

Sparse fieldsets—returning only requested fields—slash payload size by 60-80% in resource-heavy endpoints. In mobile banking, that's the difference between a responsive app and a support ticket.

Sanctum: Tokens vs. Sessions

For first-party SPAs, Sanctum's cookie-based session authentication is unbeatable. No token management. No localStorage XSS exposure. Laravel handles the CSRF and session glue.

For mobile apps and third-party integrations, I issue personal access tokens with explicit scopes:

$token = $user->createToken('mobile-app', ['payments:read', 'payments:write']);

I never issue tokens without expiration unless a compliance officer signs off on it. In banking, an immortal token is a future headline. I also rotate tokens on credential changes and provide a /logout endpoint that invalidates the token immediately—not "eventually."

API Versioning: URL vs. Header

Header versioning (Accept: application/vnd.api+json;version=2) looks elegant in architecture diagrams. In practice, it's a debugging nightmare. Your logs don't show it. curl commands get verbose. CDN cache keys become complicated.

I version in the URL path:

/api/v1/accounts /api/v2/accounts

Laravel makes this trivial:

Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::prefix('v2')->group(base_path('routes/api_v2.php'));

When v2 launches, v1 stays frozen. No retroactive changes. I give clients a 6-month deprecation window with Sunset headers and clear migration docs. Breaking changes without versioning killed an FX trading integration I worked on in 2019. Never again.

Testing APIs with Pest

If you're testing APIs with PHPUnit syntax, you're working too hard. Pest reads like a specification:

it('returns a structured user resource', function () {
    $user = User::factory()->create();

    $response = getJson("/api/v1/users/{$user->uuid}");

    $response
        ->assertOk()
        ->assertJsonStructure([
            'data' => [
                'id', 'full_name', 'created_at'
            ]
        ]);
});

For response contracts that shouldn't drift, I use snapshot testing:

it('matches the account resource snapshot', function () {
    $account = Account::factory()->create();

    expect(AccountResource::make($account)->response()->getData(true))
        ->toMatchSnapshot();
});

If a developer adds a field to the resource without updating tests, the snapshot fails immediately. In teams of twenty-plus engineers, this is your safety net against silent contract changes.

I also write feature tests that hit real validation rules, rate limits, and authentication guards. Mocking the auth layer is fine for unit tests. API feature tests run the full stack.

Performance: Eager Loading, Caching, and Query Discipline

The N+1 query is the oldest sin in Laravel. I enforce it with strict model loading:

// Dangerous
$users = User::all();
foreach ($users as $user) {
    echo $user->account->balance; // N+1
}

// Safe
$users = User::with('account')->get();

I also run Model::preventLazyLoading(! app()->isProduction()) in local environments. It throws exceptions on accidental lazy loads during development.

For read-heavy endpoints—exchange rates, product catalogs, reference data—I cache the API response directly:

public function index()
{
    return Cache::remember('api:v1:exchange-rates', 60, function () {
        return ExchangeRateResource::collection(ExchangeRate::all());
    });
}

And I never SELECT * on large tables. I specify exact columns:

Transaction::select('id', 'amount', 'status', 'created_at')->get();

This reduces memory pressure, network I/O, and serialization overhead. At scale, it matters more than your framework choice.

Final Word

A production API isn't a collection of endpoints. It's a commitment to stability, security, and predictability. Laravel gives you the tools. It's your job to wield them with discipline.

I've inherited APIs that were "fine" until they weren't—until a leaked field triggered a compliance audit, or a missing rate limit enabled credential stuffing, or an unoptimized query crashed the ledger during market volatility. The patterns above aren't theory. They're scars.

Build APIs that survive the night. Your future self—and your fraud team—will thank you.

Bashar Ayyash
AUTHOR

Bashar Ayyash (Yabasha)

AI Systems Architect for regulated industries — evals, harness design, AI security.

Bashar Ayyash is an AI engineer and dev lead in Amman, Jordan. 20 years shipping software, 4 years inside Alrajhi Bank building production RAG and agent systems with evals, guardrails and monitoring — in Arabic and English. He writes at yabasha.dev and builds open-source tooling for AI-assisted development.

Newsletter

Practical AI + full-stack insights for MENA builders. No spam.

Read more on the blog

Browse the latest articles or explore the full archive.