log in
consulting hosting industries the daily tools about contact

Inngest vs. Laravel Queues: Where the Tradeoffs Actually Land

I swapped Laravel queues for Inngest on a multi-step workflow project and learned exactly where the abstraction earns its keep — and where it costs you.

Inngest sold me on the demo and then made me earn it in production. After shipping it on a healthcare-adjacent intake workflow last year, I have a clearer picture of where it genuinely beats Laravel queues and where you're paying a complexity tax for something you didn't need.

The short version: if your workflow has more than three sequential async steps, fan-outs, or human wait states, Inngest is worth the pain. If you're just processing jobs in the background, stick with Horizon.

What Problem Inngest Is Actually Solving

Laravel queues are great at "do this thing later." You dispatch a job, it runs, it retries if it fails. That model works until your "thing" is actually five things that depend on each other, some of which wait on external events, and you need to know exactly where a given run broke when something goes wrong at 2am.

The classic workaround is chaining jobs or using job batches with callbacks. I've written those chains. They work until they don't — and when they break mid-chain, your observability is whatever you bolted on yourself. You're reconstructing state from logs.

Inngest's model is different. You write a function with discrete steps. Each step is checkpointed. If step 3 fails, the whole function replays from the last successful checkpoint. You get a timeline view of every run in their dashboard. You can wait for external events inside a function — literally sleep for 72 hours and not hold a queue worker thread.

For the project that pushed me toward it: a patient intake flow that triggered document collection, waited for a third-party e-signature service to webhook back, ran an eligibility check against an insurance API, and then either auto-approved or routed to a human review queue. That's not a job chain. That's a workflow, and pretending otherwise was the source of every bug.

Connecting Inngest to a Laravel App

Inngest communicates over HTTP. Your app exposes an endpoint; Inngest calls it to execute steps. In Laravel, that means a route and a controller that speaks the Inngest SDK protocol. There's no official Laravel SDK as of this writing — the SDK is JavaScript/TypeScript first. So you're either writing raw PHP or using one of the community PHP implementations.

I ended up writing a thin integration layer. Here's a simplified version of the pattern:

// routes/api.php
Route::post('/inngest', [InngestController::class, 'handle']);

// app/Http/Controllers/InngestController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

class InngestController extends Controller
{
    public function handle(Request request): JsonResponse
    {
        $body = $request->getContent();
        $headers = $request->headers->all();

        // Verify the request signature from Inngest
        $this->verifySignature($body, $headers);

        $payload = json_decode($body, true);
        $functionId = $payload['ctx']['fn_id'] ?? null;

        $registry = app(InngestFunctionRegistry::class);
        $fn = $registry->resolve($functionId);

        if (!$fn) {
            return response()->json(['error' => 'Function not found'], 404);
        }

        return $fn->execute($payload);
    }

    private function verifySignature(string $body, array $headers): void
    {
        $sig = $headers['x-inngest-signature'][0] ?? null;
        $key = config('services.inngest.signing_key');

        // Inngest uses HMAC-SHA256 with timestamp to prevent replay attacks
        [$ts, $hash] = $this->parseSigHeader($sig);

        $expected = hash_hmac('sha256', 'signkey-prod-' . $key . $ts . $body, $key);

        if (!hash_equals($expected, $hash)) {
            abort(401, 'Invalid Inngest signature');
        }
    }
}

The step execution protocol is where it gets interesting. Inngest calls your endpoint multiple times for a single function run — once per step, replaying completed steps and executing the next one. Your step handlers need to be idempotent and you return a specific response format to signal completion or to yield a "wait for event" state:

// app/Inngest/Functions/PatientIntakeFunction.php
namespace App\Inngest\Functions;

use Illuminate\Http\JsonResponse;

class PatientIntakeFunction
{
    public string $id = 'patient/intake-workflow';

    public function execute(array $payload): JsonResponse
    {
        $steps = $payload['steps'] ?? [];
        $event = $payload['event'];
        $patientId = $event['data']['patient_id'];

        // Step 1: Request e-signature document
        if (!isset($steps['request-esign'])) {
            $docId = $this->requestEsignDocument($patientId);

            return response()->json([
                [
                    'id' => 'request-esign',
                    'op' => 'StepRun',
                    'data' => ['doc_id' => $docId],
                ]
            ]);
        }

        $docId = $steps['request-esign']['data']['doc_id'];

        // Step 2: Wait for the e-signature webhook event
        if (!isset($steps['wait-for-esign'])) {
            return response()->json([
                [
                    'id' => 'wait-for-esign',
                    'op' => 'WaitForEvent',
                    'name' => 'esign/document.signed',
                    'expression' => "event.data.doc_id == '{$docId}'",
                    'timeout' => '72h',
                ]
            ]);
        }

        // Step 3: Run eligibility check
        if (!isset($steps['eligibility-check'])) {
            $result = $this->checkInsuranceEligibility($patientId);

            return response()->json([
                [
                    'id' => 'eligibility-check',
                    'op' => 'StepRun',
                    'data' => $result,
                ]
            ]);
        }

        // Final: route to approval or human review
        $eligible = $steps['eligibility-check']['data']['eligible'];
        $this->finalizeIntake($patientId, $eligible);

        return response()->json(null, 206); // 206 = function complete
    }

    private function requestEsignDocument(int $patientId): string
    {
        // Call your e-signature provider, return document ID
        return app(EsignService::class)->createDocument($patientId);
    }

    private function checkInsuranceEligibility(int $patientId): array
    {
        return app(InsuranceService::class)->checkEligibility($patientId);
    }

    private function finalizeIntake(int $patientId, bool $eligible): void
    {
        // Write to DB, notify staff, etc.
    }
}

This is simplified — the real Inngest step protocol has more to it — but the mental model is accurate. Your function is called repeatedly with accumulated step results, and you keep returning "here's the next thing to do" until you're done.

The Gotchas That Bit Me

The replay model requires truly idempotent steps. Every step before the current one re-runs its logic to reconstruct state before executing. If step 1 fires an email and step 2 is what failed, Inngest will re-execute step 1's code path on retry — but expects you to return the same result without side effects. The SDK in JS handles this by caching step results. In raw PHP, you're managing that yourself. I missed this on my first pass and sent duplicate emails. Not great in healthcare.

The fix: check your own database before executing the side effect.

private function requestEsignDocument(int $patientId): string
{
    $existing = EsignDocument::where('patient_id', $patientId)
        ->where('status', 'pending')
        ->first();

    if ($existing) {
        return $existing->external_id;
    }

    return app(EsignService::class)->createDocument($patientId);
}

The dev server setup is non-trivial. Inngest Cloud needs to reach your local machine to call your endpoint during development. You use inngest dev (their CLI) and tunnel via their proxy or ngrok. It works, but it adds friction to the local loop. I'm used to php artisan queue:work — this feels heavier.

PHP is a second-class citizen. The official SDK is TypeScript. The protocol is documented well enough to implement against, but you're not getting first-class tooling, typed SDK helpers, or guaranteed SDK compatibility as they evolve the protocol. If you're running a Node/Next.js shop, the DX is considerably smoother. For a PHP shop, budget time for glue.

Event schema discipline matters immediately. Inngest is event-driven. Every trigger and wait-for-event is matched by event name and an expression. If your event schema is loose — different keys, inconsistent naming — your wait expressions break silently. I now treat Inngest events like a public API: versioned, documented, never casually modified.

Pricing model at scale. Inngest charges per step execution. For low-volume workflows with many steps, it's negligible. If you're processing thousands of runs with 10+ steps each, do the math before you commit. It's not a queue bill — it's a per-execution bill.

When I'd Reach for Inngest

Use it when:

  • You have multi-step workflows with external wait states (webhooks, human approval, timed delays). This is the killer use case. Laravel can't natively sleep a job for 72 hours without holding a worker.
  • You need first-class run observability without building it yourself. The dashboard showing each step's input/output and timing has saved me hours of debugging.
  • Your workflow has fan-out patterns — trigger 50 sub-workflows and wait for all of them. Doing this cleanly with job batches is doable but ugly.
  • You're in a domain where audit trails matter (healthcare, finance, anything compliance-adjacent). Every step is logged automatically.

Don't use it when:

  • You're just running background jobs — sending emails, resizing images, kicking off reports. Laravel Horizon with Redis is faster to set up, cheaper, and you already know it.
  • Your team is PHP-only and you're not prepared to maintain the integration layer. The JS SDK is just better and you'll fight the tooling gap indefinitely.
  • You need sub-second job processing. Inngest adds HTTP round-trip overhead per step. It's not designed for high-frequency low-latency work.

Closing

Inngest is a genuinely good idea that's clearly designed for JavaScript shops first. If you're running Laravel and your workflows are getting complicated enough to warrant it, the tradeoffs are manageable — but you're writing your own SDK effectively, and that cost is real. For the intake workflow I shipped, it was worth it: the observability alone justified the integration time. For a simpler project, I'd have stayed with Horizon and a well-structured job chain and not looked back.

Need help shipping something like this? Get in touch.