So, you've been asked to build a small service.

Maybe it's a webhook receiver that listens for events from Stripe and writes them somewhere. Maybe it's a thin authentication broker that sits in front of three other services. Maybe it's a public-facing API that does ten things, none of them complicated, but all of them have to be fast.

Whatever it is, your first instinct as a Laravel developer is the right one: "this doesn't need the whole framework. This needs Lumen."

And five years ago, that instinct was almost always correct. Lumen was the official answer to "I want Laravel ergonomics without the Laravel weight." Strip out Blade, drop facades, lose half the service providers, and what you got was a request that booted in a fraction of the time and used a fraction of the memory.

The instinct is still understandable in 2021. The answer is more complicated. Let's walk through it.

What Lumen actually is

Lumen is a microframework built by the Laravel team on top of the same Illuminate components Laravel uses. Same router idea, same container, same validation, same Eloquent if you turn it on. The difference isn't the building blocks; it's how many of them are wired up by default.

A fresh Laravel install gives you full HTTP middleware, full route caching, Blade, facades, the whole App\Http\Kernel, App\Console\Kernel, broadcasting, mail, notifications, queue workers. Everything is registered and ready, even if your routes/web.php is empty. You pay a small per-request cost for that readiness.

A fresh Lumen install gives you a single bootstrap/app.php file where almost everything is commented out:

PHP bootstrap/app.php
$app = new Laravel\Lumen\Application(
    dirname(__DIR__)
);

// $app->withFacades();
// $app->withEloquent();

// $app->singleton(
//     Illuminate\Contracts\Debug\ExceptionHandler::class,
//     App\Exceptions\Handler::class
// );

// $app->middleware([
//     App\Http\Middleware\ExampleMiddleware::class
// ]);

// $app->register(App\Providers\AppServiceProvider::class);

return $app;

You opt into things one line at a time. Need Eloquent? Uncomment withEloquent(). Need facades? Uncomment withFacades(). Need a service provider? Uncomment its register(). The framework starts with almost nothing and grows as you add lines back.

The router is a big part of what makes Lumen feel different. Laravel uses Symfony's routing component plus its own layer on top: flexible, slow to register, fast to dispatch when cached. Lumen uses FastRoute, which is exactly what its name implies: a tight, single-purpose dispatcher with very little ceremony. Routes look familiar:

PHP routes/web.php
$router->group(['prefix' => 'api/v1'], function () use ($router) {
    $router->post('/webhooks/stripe', 'WebhookController@stripe');
    $router->get('/health', fn () => ['ok' => true]);
});

But the path from request-arrival to controller-invocation is shorter than Laravel's. There's no full Kernel::handle() pipeline by default, no RouteServiceProvider, no HandleCors, no VerifyCsrfToken, no ShareErrorsFromSession. There's a router, a thin pipeline of whatever middleware you opted into, and your controller.

That's the core promise: less wiring means less cost per request.

What you give up, and it's more than people remember

The "less wiring" sales pitch is real. It's also incomplete, because the things Lumen drops aren't all weight. Some of them are productivity you don't notice until you reach for them.

The most painful one is Artisan generators. In Laravel:

Bash
php artisan make:controller WebhookController
php artisan make:request StoreWebhookRequest
php artisan make:resource WebhookResource
php artisan make:migration create_webhooks_table
php artisan make:job ProcessStripeEvent
php artisan make:notification WebhookFailed

In Lumen, only a small subset of these exist. There's no make:controller, no make:request (FormRequest itself isn't shipped; you validate inline on the request object), no make:resource, no make:notification. You write those classes by hand, or you copy the boilerplate from a Laravel project. Not the end of the world, but every keystroke that didn't happen in Laravel happens here, every time, on every project.

Form requests are the one that hurts the most for API services. In Laravel, FormRequest lets you put validation rules and authorisation in a typed class:

PHP app/Http/Requests/StoreOrderRequest.php
class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create-order');
    }

    public function rules(): array
    {
        return [
            'sku'   => ['required', 'string', 'exists:products,sku'],
            'qty'   => ['required', 'integer', 'min:1'],
            'email' => ['required', 'email'],
        ];
    }
}

In your controller, you type-hint the request and the framework runs validation before your method body ever executes. In Lumen, you do this inline:

PHP app/Http/Controllers/OrderController.php
public function store(Request $request)
{
    $this->validate($request, [
        'sku'   => 'required|string|exists:products,sku',
        'qty'   => 'required|integer|min:1',
        'email' => 'required|email',
    ]);

    // ...
}

Same validation rules. Same Illuminate validator under the hood. But the rules now live next to the business logic instead of in their own typed class, and you can't easily reuse them across endpoints. For a service with three routes, it's fine. For a service with thirty, the duplication adds up.

API resources are similar. Laravel's JsonResource lets you describe the JSON shape of a model in one place:

PHP app/Http/Resources/OrderResource.php
class OrderResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id'         => $this->id,
            'sku'        => $this->sku,
            'qty'        => $this->qty,
            'amount'     => $this->amount,
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

In Lumen, you build the array yourself in the controller (or you pull in the illuminate/http resources package and wire it up by hand). Fine for a tiny service, less fine for one that grows.

Then there's everything that technically works in Lumen but with a sharper edge:

  • Eloquent works once you uncomment withEloquent(). But the lack of facades by default means User::find($id) won't work until you add withFacades() or you use the container directly, and the rest of the team has to remember which install they're in.
  • Queues work, using the same illuminate/queue package and drivers, but you're managing the worker yourself, with no php artisan queue:work shortcut and no make:job to scaffold jobs.
  • Mail works if you register the mail service provider manually. Same with notifications, broadcasting, and most of the optional Laravel features.
  • Service providers work, but you register them in bootstrap/app.php instead of config/app.php, and the order matters more than people realise because there's no auto-discovery.

The pattern is the same across all of these: you can have it, but you have to ask for it, and you have to know exactly what to ask for. That's fine for a small team that knows Lumen well. It's a friction tax on a team that mostly works in Laravel and switches over for one project.

The 2021 wrinkle: Laravel itself says don't

There's an awkward fact to put on the table before going further.

The Laravel team officially recommends starting new projects with Laravel, not Lumen. The Lumen documentation says it directly: "the Laravel team no longer support beginning new projects with Lumen, and instead recommend always beginning new projects with Laravel."

Lumen is still maintained: version 11 is current, it tracks the Illuminate 11.x components, and it supports modern PHP. It is not abandoned. But the team that built it has stopped pointing new work at it, and the reasons are worth understanding because they directly inform the trade-off you're trying to make.

The reasoning, paraphrased: PHP itself got faster (8.0 through 8.4 each shaved real time off cold-start and per-request cost), opcache and JIT got better, and Laravel Octane arrived. With Octane running on Swoole, RoadRunner, or FrankenPHP, the framework boots once at worker start and stays in memory across requests. The per-request cost (the thing Lumen was originally built to fight) drops by an order of magnitude. The whole reason Lumen existed is partly solved at the runtime layer now.

That doesn't mean Lumen is wrong. It means the math has shifted, and "start with Laravel by default" is the new default answer because it's correct most of the time. The question is whether your particular small service is one of the cases where the old answer still wins.

Side-by-side request lifecycle comparison: Lumen in three fast steps (Bootstrap, FastRoute dispatch, Controller), Laravel on FPM in six slower steps, and Laravel on Octane in two steps because the framework stays booted in memory between requests

Where Lumen still wins

Strip away the warmth of habit and there are real cases where Lumen is still the right answer.

Truly tiny services, deployed without Octane. If your service is going to handle a webhook every few seconds on a small VPS, you don't have an FPM pool to keep warm, you don't have the operational appetite to run a long-lived worker process, and you just want a php artisan serve (or nginx + FPM) deployment that boots cheaply per request. Lumen's lower per-request cost is a real win. The savings show up where the requests are infrequent enough that warm-process tricks don't help.

Services with a strict, hand-managed dependency surface. Lumen forces you to opt into every Illuminate package you use. For a team that wants to know exactly what's loaded ("this service uses cache, queue, and validation, and nothing else"), Lumen's composer.json and bootstrap/app.php together act as a manifest. A Laravel install pulls in dozens of packages whether you touch them or not.

Services where the team's definition of "small" is ironclad. This is the cultural one. Lumen's lack of generators and scaffolding makes it slightly painful to grow. That pain is a feature if your team uses it as a forcing function: "if this service is starting to need form requests and notifications and console commands, it's outgrown its mandate, and we should split it or promote it to Laravel". A Laravel install lets a small service grow into a monolith without anyone noticing. Lumen will complain.

Existing Lumen services. Don't migrate just because the recommendation changed. A Lumen 11 service running on PHP 8.4 is fine. The team is still shipping fixes. The migration cost from Lumen to Laravel is real (different bootstrap, different config layout, different Artisan surface) and should only be paid when you have a concrete reason, usually because you're hitting Lumen's ergonomic ceiling, not because some upgrade guide implied you have to.

Where Laravel wins even for small services

The flip side is that there are now plenty of cases where Laravel is the better choice even for the small service the old you would have built in Lumen.

You expect the service to grow. "Small" is a temporary state for most services. You wrote a webhook receiver, six months later it's also a job dispatcher, six months after that it's also got an admin dashboard with auth, and now you're regretting the decision to skip Blade and facades. Laravel scales smoothly from "tiny" to "medium" because it was already tiny-plus-everything. Lumen scales by either turning into Laravel-with-extra-steps or by being rewritten into Laravel.

You're going to deploy on Octane (or FrankenPHP, or RoadRunner). Once the framework is in memory between requests, Laravel's bootstrap cost (the original reason for Lumen) stops being a per-request tax. It becomes a one-time cost paid at worker start. The performance gap between a Laravel-on-Octane app and a Lumen-on-FPM app shrinks dramatically, and Laravel-on-Octane often wins outright on raw throughput.

Your team is mostly Laravel. The mental tax of remembering "in this repo User::find() won't work because we forgot withFacades()" and "in this repo there's no make:controller" compounds across people. Even if the service itself is small, the cognitive surface of "which framework am I in" costs more than the few milliseconds you saved per request.

You want first-party tooling for things you'll probably want later. Auth scaffolding, notifications, broadcasting, the queue dashboard, Octane itself, Pulse, Telescope, Horizon, Sanctum, Passport. These are first-party Laravel packages. Most of them either don't work in Lumen or work with significant manual wiring. You may not need them on day one, but the day you do, you'll wish you weren't fighting the framework to add them.

A worked example: the same service, both ways

Let's compare the same tiny service (a webhook endpoint that takes a JSON payload, validates it, persists it, and dispatches a job) in both frameworks.

In Lumen:

PHP lumen/routes/web.php
$router->post('/webhooks/stripe', 'WebhookController@store');
PHP lumen/app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;

use App\Jobs\ProcessStripeEvent;
use App\Models\WebhookEvent;
use Illuminate\Http\Request;

class WebhookController extends Controller
{
    public function store(Request $request)
    {
        $this->validate($request, [
            'id'   => 'required|string',
            'type' => 'required|string',
            'data' => 'required|array',
        ]);

        $event = WebhookEvent::create([
            'external_id' => $request->input('id'),
            'type'        => $request->input('type'),
            'payload'     => $request->input('data'),
        ]);

        dispatch(new ProcessStripeEvent($event->id));

        return response()->json(['ok' => true], 202);
    }
}

In Laravel:

PHP laravel/routes/api.php
Route::post('/webhooks/stripe', [WebhookController::class, 'store']);
PHP laravel/app/Http/Requests/StoreStripeEventRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreStripeEventRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'id'   => ['required', 'string'],
            'type' => ['required', 'string'],
            'data' => ['required', 'array'],
        ];
    }
}
PHP laravel/app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;

use App\Http\Requests\StoreStripeEventRequest;
use App\Jobs\ProcessStripeEvent;
use App\Models\WebhookEvent;

class WebhookController extends Controller
{
    public function store(StoreStripeEventRequest $request)
    {
        $event = WebhookEvent::create([
            'external_id' => $request->input('id'),
            'type'        => $request->input('type'),
            'payload'     => $request->input('data'),
        ]);

        ProcessStripeEvent::dispatch($event->id);

        return response()->json(['ok' => true], 202);
    }
}

The Lumen version is shorter: one file instead of two, validation inline. That's the ergonomic argument in miniature.

The Laravel version is structurally cleaner. Validation lives in its own typed class, the controller is purely about what to do once the input is valid, and if a second endpoint needs the same validation rules, the FormRequest is reusable. That's the productivity argument in miniature.

Neither is wrong. The Lumen version costs less to write today and more to maintain in a year, especially if "this service has one endpoint" turns into "this service has fifteen endpoints." At that point, you'll wish the validation rules weren't scattered through fifteen controllers.

The runtime story is more nuanced than people remember. Under FPM, the Lumen version handles the request faster because the framework boots faster. Under Octane, both versions boot once and the per-request difference becomes much smaller. The controller code itself is doing nearly identical work either way. If you're sizing infrastructure based on requests-per-second, the runtime model you deploy on matters more than which framework you picked.

How I think about it now

Five years ago, I'd have reached for Lumen reflexively for anything I called a "service". Today, the heuristic I actually use is shorter:

  • Will this service ever need auth, generated scaffolding, queue dashboards, or any of the first-party Laravel ecosystem packages? -> Laravel.
  • Is there any chance this service will grow past, say, ten endpoints in its lifetime? -> Laravel.
  • Will this be deployed on Octane, FrankenPHP, or any persistent worker model? -> Laravel. The bootstrap-cost argument for Lumen mostly evaporates.
  • Is the team comfortable with both frameworks, and willing to maintain Lumen's "you ask for what you need" model long-term? -> Lumen is still fine.
  • Is this an existing Lumen service that's working? -> Leave it on Lumen unless you have a concrete reason to migrate.

That's not "Lumen is dead". That's "the trade-off has shifted, and the case for Lumen is narrower than it used to be, but the cases where it still applies are real and worth respecting".

The instinct to reach for the smaller tool when the job is small is correct. Just check whether the smaller tool is actually smaller in 2021. Once you account for ergonomics, ecosystem, runtime model, and the very real possibility that "small" doesn't last, you may find that the full framework, on a modern runtime, is the smaller tool you were looking for all along.