So, you've got a small service to build: a webhook receiver, an internal API for a mobile app, a microservice that does one thing well. You reach for Laravel because that's what you know, and ten minutes in you realize you don't need Blade, you don't need sessions, you don't need a queue worker, you don't need broadcasting, and you definitely don't need the full kitchen sink to return a few hundred JSON responses a second.
That's the slot Lumen was built to fill.
Lumen is Laravel's micro-framework cousin. Same Illuminate components underneath, same syntax for routing and validation and middleware, but stripped down to a thin shell that boots faster and ships smaller. You get the parts you use when you're writing an API, and the rest stays out of the way.
This piece walks through the five pieces you'll touch on day one (routing, controllers, middleware, validation, and responses) with enough detail that you can stand up a real endpoint without copy-pasting from the docs every five minutes. We'll also have an honest moment about where Lumen sits today and when reaching for full Laravel is the better call.
What Lumen Actually Strips Out
Before any code, it helps to know what's missing, because the missing pieces are exactly what makes Lumen feel different.
In a fresh Lumen install, the following are off by default:
- Facades. No
DB::table(...), noCache::get(...), noAuth::user(). You inject things, or you turn facades back on inbootstrap/app.phpwith one line. - Eloquent. The ORM is in the box but disabled. Same deal: one line in
bootstrap/app.phpflips it on. - Sessions. Stateless by design. APIs don't need them.
- Views and Blade. No template engine. You're returning JSON.
- Cookies, CSRF. Same reason: stateless APIs don't carry these.
config/files. Lumen reads from.envdirectly. There's noconfig/database.phpyou can tweak unless you copy one in from Laravel and load it manually.
Everything else (the container, the request lifecycle, validation, the queue contracts, mail, logging, the cache abstraction) is still there. Lumen is Laravel with the front door narrowed.
The trade-off used to be performance. A few years back Lumen booted measurably faster than Laravel on every request, which mattered for hot-path APIs. That gap has closed a lot since Laravel adopted route caching and OPcache-friendly autoloading. We'll come back to that at the end. For now, treat Lumen as Laravel with fewer ceremonies, which is still a useful thing even if it's not the speed weapon it once was.

Routing
Lumen uses FastRoute under the hood, not Laravel's router. The API on top is almost identical though, and if you've written Laravel routes you'll feel at home immediately.
Routes live in routes/web.php (the file is named web.php for legacy reasons; don't read anything into the name, all your API routes go here). The $router instance comes pre-bound at the top of the file.
<?php
/** @var \Laravel\Lumen\Routing\Router $router */
$router->get('/', function () {
return ['service' => 'orders-api', 'version' => '1.4.0'];
});
$router->get('/health', 'HealthController@check');
A controller reference is Class@method. Closures work for trivial endpoints (the health check above could stay a closure forever) but anything with real logic belongs in a controller.
Route Groups
The grouping API is the single biggest reason routes stay readable. You group by prefix, by middleware, by namespace, or by all three at once.
$router->group(['prefix' => 'api/v1'], function () use ($router) {
$router->get('orders', 'OrdersController@index');
$router->get('orders/{id}', 'OrdersController@show');
$router->post('orders', 'OrdersController@store');
$router->patch('orders/{id}', 'OrdersController@update');
$router->delete('orders/{id}', 'OrdersController@destroy');
});
The use ($router) is the small gotcha. Lumen's closure doesn't auto-bind $router the way Laravel's does, so you import it explicitly. Forget that one detail and you'll get an Undefined variable error inside the group. First time it bites, you'll remember it forever.
You can nest groups, which is where this starts paying off:
$router->group(['prefix' => 'api/v1'], function () use ($router) {
// public
$router->post('login', 'AuthController@login');
// authenticated
$router->group(['middleware' => 'auth'], function () use ($router) {
$router->get('me', 'AuthController@me');
$router->post('logout', 'AuthController@logout');
// admin only
$router->group(['middleware' => 'role:admin'], function () use ($router) {
$router->get('users', 'AdminController@listUsers');
$router->delete('users/{id}', 'AdminController@deleteUser');
});
});
});
Three groups, three concerns: public, authenticated, admin. The route file reads like a permission map.
Route Parameters
Parameters in the path are {name} and arrive as method arguments in the controller, in the order they appear.
$router->get('orders/{orderId}/items/{itemId}', 'OrdersController@showItem');
public function showItem($orderId, $itemId)
{
// both are strings, cast or validate as needed
}
Constraints go through a regex on the route:
$router->get('orders/{id:[0-9]+}', 'OrdersController@show');
That :[0-9]+ says "this segment must match the regex." Anything else returns a 404 before the controller even runs. Useful when you want IDs to be numeric and don't want to write a guard inside every action.
Controllers
A Lumen controller is just a class. There's a base controller, Laravel\Lumen\Routing\Controller, that gives you two traits worth keeping: ProvidesConvenienceMethods (which is where $this->validate() lives) and AuthorizesRequests for policies.
<?php
namespace App\Http\Controllers;
use App\Order;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class OrdersController extends Controller
{
public function index(Request $request)
{
$orders = Order::query()
->where('customer_id', $request->user()->id)
->orderByDesc('created_at')
->paginate(20);
return response()->json($orders);
}
public function show($id)
{
$order = Order::findOrFail($id);
return response()->json($order);
}
public function store(Request $request)
{
$data = $this->validate($request, [
'sku' => 'required|string',
'quantity' => 'required|integer|min:1',
]);
$order = Order::create([
'customer_id' => $request->user()->id,
'sku' => $data['sku'],
'quantity' => $data['quantity'],
]);
return response()->json($order, Response::HTTP_CREATED);
}
}
Note the things that aren't there. There's no use App\Http\Requests\StoreOrderRequest, because Lumen doesn't ship Laravel's Form Request classes. Validation happens inline with $this->validate(), which we'll dig into properly in a minute.
The Request instance gets injected by type-hint. That's classic Laravel container behaviour and it works here without any extra setup.
Dependency Injection
Constructor injection works the same as in Laravel. The container resolves type-hints all the way down.
class OrdersController extends Controller
{
public function __construct(
private OrderService $orders,
private NotificationGateway $notifications,
) {}
public function store(Request $request)
{
$data = $this->validate($request, [/* ... */]);
$order = $this->orders->create($data);
$this->notifications->orderPlaced($order);
return response()->json($order, 201);
}
}
If OrderService itself depends on something (a repository, an HTTP client, a config value) the container resolves the whole chain. You don't have to wire anything in bootstrap/app.php unless you're binding an interface to a concrete implementation. In that case:
$app->bind(
\App\Contracts\PaymentGateway::class,
\App\Services\StripeGateway::class
);
Same as Laravel. The only difference is where you put it. There's no AppServiceProvider by default in Lumen, so bindings go either directly in bootstrap/app.php or in a provider you register manually with $app->register(YourProvider::class).
Middleware
Middleware in Lumen is structurally the same as Laravel: a class with a handle($request, Closure $next) method that either calls $next($request) or returns a response early.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class RequireApiKey
{
public function handle(Request $request, Closure $next)
{
$key = $request->header('X-API-Key');
if (! $key || $key !== config('app.api_key')) {
return response()->json(['error' => 'invalid api key'], 401);
}
return $next($request);
}
}
What's different is registration. Lumen doesn't have the app/Http/Kernel.php you see in Laravel. Instead, middleware gets registered in bootstrap/app.php in one of two arrays.
// Global: runs on every request.
$app->middleware([
App\Http\Middleware\AddCorsHeaders::class,
App\Http\Middleware\RequestId::class,
]);
// Route-bound: only runs when referenced by alias in routes/web.php.
$app->routeMiddleware([
'auth' => App\Http\Middleware\Authenticate::class,
'api_key' => App\Http\Middleware\RequireApiKey::class,
'role' => App\Http\Middleware\CheckRole::class,
]);
The global array is the equivalent of Laravel's $middleware property. The routeMiddleware array is the equivalent of $routeMiddleware. The naming is literally that close.
From the route file, route middleware is applied either as a group setting or per-route:
$router->group(['middleware' => ['api_key', 'auth']], function () use ($router) {
// ...
});
$router->get('webhooks/stripe', [
'middleware' => 'verify_stripe_signature',
'uses' => 'WebhookController@stripe',
]);
Middleware Parameters
Sometimes you want a single class to cover several variants: require role X, throttle to N per minute, check ownership of resource Y. Pass parameters by appending :value to the alias.
$router->group(['middleware' => 'role:admin'], function () use ($router) { /* ... */ });
$router->group(['middleware' => 'role:editor'], function () use ($router) { /* ... */ });
public function handle(Request $request, Closure $next, string $role)
{
if (! $request->user() || ! $request->user()->hasRole($role)) {
return response()->json(['error' => 'forbidden'], 403);
}
return $next($request);
}
That third parameter is the value after the colon. You can pass several (role:admin,editor) and they arrive as separate arguments.
After Middleware
Anything you do after $next($request) runs on the way out, with the response in hand.
public function handle(Request $request, Closure $next)
{
$start = microtime(true);
$response = $next($request);
$durationMs = (int) ((microtime(true) - $start) * 1000);
$response->headers->set('X-Response-Time-Ms', $durationMs);
return $response;
}
This is the cheap-and-effective way to add request IDs, timing headers, structured access logs, anything that wants to wrap the whole request rather than guard it.
Validation
Validation is the place Lumen most obviously inherits from Laravel without the ceremony. There's no Form Request class to scaffold, no authorize() method to remember to set, no separate file per action. You call $this->validate($request, $rules) and Lumen does three things:
- Runs the rules against the input.
- If anything fails, throws a
ValidationException. - The exception handler converts that exception into a
422 Unprocessable Entityresponse with a JSON body listing the errors. Automatically.
You don't catch the exception. You don't format the error response. It just happens.
public function store(Request $request)
{
$data = $this->validate($request, [
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8|confirmed',
'role' => 'required|in:admin,editor,viewer',
'profile.bio' => 'nullable|string|max:500',
'tags' => 'array',
'tags.*' => 'string|max:30',
]);
$user = User::create([
'email' => $data['email'],
'password' => bcrypt($data['password']),
'role' => $data['role'],
]);
return response()->json($user, 201);
}
The return value of $this->validate() is the filtered input: only the fields you wrote rules for. Anything the client sent that you didn't ask about is dropped. That's a quietly important habit; it means you can mass-assign $data into a model without worrying about a sneaky extra field.
The response body for a 422 looks like this:
{
"email": ["The email has already been taken."],
"password": ["The password confirmation does not match."],
"role": ["The selected role is invalid."]
}
That shape is fixed. If you want a different shape (envelope your errors under a key, add an error code, return RFC 7807-style problem details), override the validator in the global exception handler.
Custom Validation Responses
Open app/Exceptions/Handler.php and find render(). By default it dispatches ValidationException to its parent, which returns the body above. You can intercept.
use Illuminate\Validation\ValidationException;
public function render($request, Throwable $exception)
{
if ($exception instanceof ValidationException) {
return response()->json([
'error' => 'validation_failed',
'message' => 'The submitted data did not pass validation.',
'fields' => $exception->errors(),
], 422);
}
return parent::render($request, $exception);
}
Now every 422 from every controller follows the same envelope, without touching a single controller method.
Validating Outside Controllers
$this->validate() only exists because the base controller uses a trait. Outside that (in a job, a console command, a service class) use the Validator facade (if you've enabled facades) or resolve it from the container.
use Illuminate\Support\Facades\Validator;
$validator = Validator::make($input, [
'sku' => 'required|string',
'quantity' => 'required|integer|min:1',
]);
if ($validator->fails()) {
throw new \DomainException('invalid input: ' . $validator->errors()->first());
}
$data = $validator->validated();
Same rule strings, same error messages, just a thin layer of plumbing on top.
Responses
The response layer is where Lumen feels most like Laravel. The response() helper, the Response class, the JSON helpers: all of it ports over unchanged.
return response()->json($payload); // 200 OK
return response()->json($payload, 201); // 201 Created
return response()->json(null, 204); // 204 No Content
return response('plain text body', 200, ['Content-Type' => 'text/plain']);
For status codes I lean on the Response constants; they're more readable than bare integers at the call site.
use Illuminate\Http\Response;
return response()->json($order, Response::HTTP_CREATED); // 201
return response()->json(null, Response::HTTP_NO_CONTENT); // 204
return response()->json($error, Response::HTTP_UNPROCESSABLE_ENTITY);// 422
return response()->json($error, Response::HTTP_TOO_MANY_REQUESTS); // 429
Returning an Eloquent model or collection works too. Lumen serialises it through the model's toArray():
public function show($id)
{
return Order::with('items')->findOrFail($id);
}
That ships back as JSON automatically. The with('items') pulls the relationship, and as long as toArray() returns what you want, you don't need response()->json() at all. It's the same shortcut you have in Laravel.
Headers
response()->json() returns a JsonResponse instance, and you can fluently chain headers onto it.
return response()
->json($order, 201)
->header('Location', "/api/v1/orders/{$order->id}")
->header('X-Resource-Id', $order->id);
For idempotent endpoints, an ETag header plus a 304 short-circuit lives in this same lane:
$etag = md5($order->updated_at);
if ($request->header('If-None-Match') === $etag) {
return response('', 304);
}
return response()->json($order)->header('ETag', $etag);
That's a real perf win on read-heavy endpoints, and the only Lumen-specific thing about it is "yes, it works exactly like Laravel."
Streaming Responses
For big payloads (exports, log dumps, anything where loading the whole body into memory is a problem) response()->stream() lets you push chunks as you go.
return response()->stream(function () {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['id', 'sku', 'quantity', 'created_at']);
Order::query()->orderBy('id')->chunk(500, function ($orders) use ($handle) {
foreach ($orders as $order) {
fputcsv($handle, [$order->id, $order->sku, $order->quantity, $order->created_at]);
}
});
fclose($handle);
}, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="orders.csv"',
]);
The combination of stream() plus Eloquent::chunk() is what lets you export a 200,000-row table without the PHP process climbing past a hundred megs of RAM.
A Note On Where Lumen Stands Today
Worth being straight about this. Lumen has been in maintenance mode for a while. Bug fixes and security patches still ship, but the framework isn't getting new features. Taylor Otwell's recommendation for new projects has been "use Laravel directly" for some time now, because the performance gap between the two has narrowed enough that the trade-off shifted.
That doesn't make Lumen useless. It means the situations where reaching for it makes sense are narrower than they used to be.
It's a fine pick when:
- You're maintaining an existing Lumen service and need to extend it.
- You want the smallest possible composer footprint for a single-purpose worker or webhook receiver and the absence of sessions/views/Blade is a real subtraction.
- You're learning the Illuminate components without the surface area of full Laravel getting in the way. Lumen is genuinely a more readable codebase if you want to trace how routing and middleware connect.
It's not the call when:
- You're starting a new service that might grow into a normal application. You'll spend the saved minutes turning facades and Eloquent and config files back on within a week.
- You need a feature Lumen doesn't carry (broadcasting, the full notification system, Blade) and would have to bolt back on by hand.
For everything in between, Laravel with route caching and OPcache will get you within a few percent of Lumen's throughput on most workloads, and you keep all the conveniences. Pick deliberately.
The Shape Of A Real Endpoint
Putting all five pieces together (routing, controller, middleware, validation, response) the lifecycle of a single endpoint reads like this:
$router->group([
'prefix' => 'api/v1',
'middleware' => ['api_key', 'auth'],
], function () use ($router) {
$router->post('orders', 'OrdersController@store');
});
public function store(Request $request)
{
$data = $this->validate($request, [
'sku' => 'required|string|exists:products,sku',
'quantity' => 'required|integer|min:1|max:100',
]);
$order = $this->orders->place(
customer: $request->user(),
sku: $data['sku'],
quantity: $data['quantity'],
);
return response()
->json($order, Response::HTTP_CREATED)
->header('Location', "/api/v1/orders/{$order->id}");
}
That's the whole loop. Middleware authenticates the request before the controller runs. Validation throws on bad input, the exception handler returns a 422, and the controller never sees malformed data. The service does the work. The response goes back as JSON with a Location header that points to the created resource.
Nothing in that flow is unique to Lumen. The whole point is that the pattern is the same one you'd write in Laravel, just with less framework underneath, and a bootstrap/app.php instead of a sprawl of providers and config files. Whether that's a feature or a missing feature depends on what you're building. Now you've got enough of the shape to make the call yourself.






