Skip to main content

Laravel API Development — What We Learned After 5 Years in Production

API lessons from 5 years in production: authentication, webhooks, validation, rate limiting, and error handling. What works and what we'd do differently.

Eyal Gantz Eyal Gantz
|
4 min read
Laravel API code with authentication middleware

Here's what we'd do differently if we were starting today — and what we'd keep exactly the same.

The Architecture That Survived

At first we over-engineered. Services calling services calling repositories. Abstractions for abstractions.

After five years and countless changes, here's what the battle-tested architecture looks like:

// Simple. Direct. Maintainable.
class OrderController extends Controller
{
    public function store(StoreOrderRequest $request): JsonResponse
    {
        $order = Order::create([
            'customer_id' => $request->customer_id,
            'status' => OrderStatus::PENDING,
            'notes' => $request->notes,
        ]);

        foreach ($request->items as $item) {
            $order->items()->create($item);
        }

        event(new OrderCreated($order));

        return response()->json([
            'data' => new OrderResource($order->load('items', 'customer')),
        ], 201);
    }
}

No service class. No repository. Just a controller that creates an order and fires an event. The event handles side effects (notifications, inventory updates, sync).

The lesson: Don't abstract until there's a reason. We removed about 40% of our service layer over five years. It added complexity without value.

Authentication — What We Got Right

We use Laravel Sanctum with tiered access:

class ApiAuthMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        // API key for external systems
        if ($request->hasHeader('X-API-Key')) {
            return $this->handleApiKey($request, $next);
        }

        // Token for internal apps (admin panel, mobile)
        if ($request->bearerToken()) {
            return $this->handleBearerToken($request, $next);
        }

        return response()->json(['error' => 'Unauthorized'], 401);
    }

    private function handleApiKey(Request $request, Closure $next): Response
    {
        $key = $request->header('X-API-Key');
        $hashedKey = hash('sha256', $key);

        $client = ApiClient::where('key_hash', $hashedKey)
            ->where('is_active', true)
            ->first();

        if (!$client) {
            return response()->json(['error' => 'Invalid API key'], 401);
        }

        // Log API usage for rate limiting and debugging
        $client->logRequest($request);
        $request->setApiClient($client);

        return $next($request);
    }
}

Why this works:

  • API keys for webhooks and external integrations
  • Bearer tokens for authenticated users (admin panel)
  • Clear separation between machine-to-machine and user authentication
  • Request logging for debugging — this saved us many times

Handling Webhooks

Most tutorials skip webhooks. Here's what we learned from handling webhooks from Shopify, Stripe, and various partners:

class WebhookController extends Controller
{
    public function shopify(Request $request): Response
    {
        // 1. Verify webhook signature first
        if (!$this->verifyShopifySignature($request)) {
            Log::warning('Invalid Shopify webhook signature', [
                'ip' => $request->ip(),
                'headers' => $request->headers->all(),
            ]);
            return response()->json(['error' => 'Invalid signature'], 401);
        }

        // 2. Save raw payload immediately (before any processing)
        $webhook = WebhookLog::create([
            'source' => 'shopify',
            'topic' => $request->header('X-Shopify-Topic'),
            'payload' => $request->all(),
            'processed' => false,
        ]);

        // 3. Send to queue for async processing
        ProcessShopifyWebhook::dispatch($webhook);

        // 4. Return 200 immediately (Shopify expects fast response)
        return response()->json(['received' => true], 200);
    }
}

Key principles:

  1. Verify signatures before processing
  2. Save raw payload immediately — gold mine for debugging
  3. Process asynchronously via queue — return 200 fast
  4. Log failures and alert on patterns

Validation — Be Paranoid

Our validation rules are strict. Very strict:

class StoreOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'customer_id' => [
                'required',
                'integer',
                Rule::exists('customers', 'id')->where('is_active', true),
            ],
            'items' => ['required', 'array', 'min:1', 'max:50'],
            'items.*.product_id' => [
                'required',
                Rule::exists('products', 'id')->where('is_active', true),
            ],
            'items.*.quantity' => ['required', 'integer', 'min:1', 'max:10'],
            'shipping_address.country' => ['required', 'string', 'size:2'],
        ];
    }
}

Why paranoid validation matters:

  • We caught a bug where Shopify sent empty arrays instead of null
  • A third-party integration tried to send 500+ items in one order (bot attack)
  • Invalid country codes broke shipping calculations three times before we added size:2

Rate Limiting — Learned the Hard Way

We didn't have rate limiting at first. Then a client's staging server got stuck in a retry loop and hit our API 50,000 times in an hour.

Now we have tiered rate limiting:

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

    if (!$client) {
        return Limit::perMinute(10)->by($request->ip());
    }

    return match ($client->tier) {
        'internal' => Limit::none(),
        'partner' => Limit::perMinute(500)->by($client->id),
        'standard' => Limit::perMinute(100)->by($client->id),
        default => Limit::perMinute(30)->by($client->id),
    };
});

The Results

After 5 years:

  • 99.9% uptime
  • ~500K requests per month
  • Response times under 100ms for most endpoints
  • Zero significant security incidents

The bottom line: Boring patterns work. Clever patterns get removed. Simple code survives.

If you're building an API, start simple. Add complexity only when there's evidence you need it. And log everything.

Want to Build an API That Runs for Years?

We'd love to talk about which patterns will survive for you too.

Eyal Gantz
Written by

Eyal Gantz

Founder & Lead Developer

Expert in e-commerce development and business automation with 10+ years of experience building custom technology solutions.

Free Strategy Call

Want to implement something similar for your business? Let's talk about how we can help you achieve results.

Start a Conversation