You have a Lumen service that's been quietly humming along for years. Maybe two endpoints. Maybe twenty. It does its job, the team rarely touches it, and life is fine.

Then someone needs auth scopes. Or scheduled jobs that don't fit cron. Or notifications. Or a queue worker with retries. Or a package that assumes full Laravel and just won't boot. And suddenly the calculus flips. Every new feature is a fight against the framework, and the "lightweight microframework" tax has overtaken the benefit.

So you start thinking about moving it onto full Laravel.

The good news: Lumen is Laravel underneath. Same container, same Eloquent, same queue components, mostly the same façade names. The bad news: the parts that aren't the same tend to be the parts your application actually depends on. Routes look almost-but-not-quite the same. Service providers get registered in a different place. Config files only exist if you opt into them. And your tests extend a base class that doesn't ship with Laravel at all.

This piece walks through the four corners that quietly break in a Lumen-to-Laravel migration (routes, service providers, config, and testing), plus the operational order that keeps the whole thing reversible if you get halfway through and have to back out.

Why migrations from Lumen go sideways

Lumen feels like Laravel, so people start a migration assuming it'll be a composer require and a few search-and-replaces. Most of the bugs come from the places where Lumen opts out of behavior that Laravel ships on.

Lumen's bootstrap/app.php is the whole personality. Facades, Eloquent, sessions, cookies, routing middleware: all of it is off until you explicitly call $app->withFacades(), $app->withEloquent(), or uncomment the relevant lines. That's why Lumen is fast to boot. It's also why moving the code into Laravel can quietly change behavior: code paths that appeared not to exist in Lumen because the feature was disabled now exist in Laravel because the feature is on.

The migration is safe when you treat it as "port the application, then turn things on deliberately" instead of "copy files and hope". The four areas below are the ones where you have to be deliberate.

Routes

In Lumen, your routes look like this:

PHP routes/web.php (Lumen)
<?php

/** @var \Laravel\Lumen\Routing\Router $router */

$router->get('/health', function () {
    return ['ok' => true];
});

$router->group(['prefix' => 'api', 'middleware' => 'auth'], function () use ($router) {
    $router->get('orders/{id}', 'OrderController@show');
    $router->post('orders', 'OrderController@store');
});

In Laravel, the equivalent is:

PHP routes/api.php or routes/web.php (Laravel)
<?php

use App\Http\Controllers\OrderController;
use Illuminate\Support\Facades\Route;

Route::get('/health', fn () => ['ok' => true]);

Route::middleware('auth')->prefix('api')->group(function () {
    Route::get('orders/{id}', [OrderController::class, 'show']);
    Route::post('orders', [OrderController::class, 'store']);
});

Two shapes change. $router becomes the Route facade, and 'Controller@action' strings become [Controller::class, 'action'] tuples. The string syntax still works in Laravel for now, but the tuple is the supported way, and your IDE will actually follow it.

A few smaller things that bite during the cutover:

Route caching behaves differently. Lumen's route:cache was added later and only caches a subset of routes. Laravel's route:cache requires every route to be in a controller; closures don't cache. If your Lumen file is half closures, expect to convert them to invokable controllers as you go. Caching is also where bad imports go to die; if php artisan route:cache blows up after the migration, missing use statements are the usual culprit.

Middleware aliases are registered in a different place. Lumen has $app->routeMiddleware([...]) in bootstrap/app.php. Laravel 10 and earlier put route middleware in app/Http/Kernel.php. Laravel 11 moved it to bootstrap/app.php again, but the API is withMiddleware(function (Middleware $middleware) { $middleware->alias([...]); }). Same idea, three different syntaxes depending on which versions you're moving between, so line them up before you start renaming usages.

Implicit binding works the same, but only after you register it. In Laravel, Route::get('orders/{order}', ...) resolves $order via Eloquent route model binding automatically. In Lumen, you had to call $router->model('order', Order::class) for the same thing. Once you're on Laravel, you can delete those explicit model() calls, but only after you've verified the parameter names in the URL match the variable names in the controller signature. The implicit binding is by name, and Lumen was happy to let you drift.

The Auth::user() in route closures requires the auth middleware to actually be applied. Lumen lets you call auth()->user() from anywhere because the auth service provider is hand-registered and bound globally. In Laravel, if you've moved a closure route over and forgotten to apply auth:api or auth:sanctum, auth()->user() returns null and you'll only notice once a test asserts on something it used to return.

The right move during the port: get every Lumen route file translated into Laravel route files first, with all controllers stubbed to return placeholder JSON. Boot the app. Hit each route with curl. Make sure the routing layer is right before you start moving the business logic, because routing bugs caught in isolation are obvious, and routing bugs caught while debugging a 500 in a fat controller are not.

Service providers

This is where Lumen-to-Laravel migrations spend the most time, because the philosophies are inverted.

Lumen registers almost nothing by default. You declare what you need in bootstrap/app.php:

PHP bootstrap/app.php (Lumen)
$app->register(App\Providers\AppServiceProvider::class);
$app->register(App\Providers\AuthServiceProvider::class);
$app->register(App\Providers\EventServiceProvider::class);

// Optional package providers - also explicit:
$app->register(Tymon\JWTAuth\Providers\LumenServiceProvider::class);

Laravel goes the other way. Depending on the version:

  • Laravel 10 and earlier: providers go in config/app.php under the providers array.
  • Laravel 11 and later: providers go in bootstrap/providers.php, and package providers are auto-discovered from composer.json under extra.laravel.providers.

So three things happen at once when you migrate:

PHP config/app.php (Laravel 10 and earlier)
'providers' => ServiceProvider::defaultProviders()->merge([
    App\Providers\AppServiceProvider::class,
    App\Providers\AuthServiceProvider::class,
    App\Providers\EventServiceProvider::class,
    App\Providers\RouteServiceProvider::class,
])->toArray(),
PHP bootstrap/providers.php (Laravel 11+)
<?php

return [
    App\Providers\AppServiceProvider::class,
];

First, your own providers move from bootstrap/app.php (Lumen) into the providers array (Laravel). Second, every package provider you were registering by hand can usually be deleted: Laravel's package auto-discovery picks them up from the package's composer.json. Third, Laravel ships several first-party providers Lumen didn't have at all (RouteServiceProvider, BroadcastServiceProvider if you enable broadcasting, etc.), and they expect to exist.

The two failure modes here:

Double registration. You leave the old explicit $app->register(...) style in some bootstrap file you forgot to delete, and the package's auto-discovered provider also runs. Most providers are idempotent, but some bind singletons twice and you end up with subtle "the second one wins" bugs.

Missing RouteServiceProvider. In Lumen, the routes file is loaded directly from bootstrap/app.php with $app->router->group([...], function ($router) { require __DIR__.'/../routes/web.php'; }). In Laravel, the RouteServiceProvider is responsible for loading routes/web.php, routes/api.php, and applying their base middleware groups. If you delete or skip that provider, your routes silently don't load and every request 404s.

A good safety net: temporarily add a dd(app()->getLoadedProviders()) in a route closure during the port. Diff the list against the Lumen app's loaded providers (you can dump the same thing in Lumen; app()->getLoadedProviders() exists there too). Anything in Lumen but not in Laravel needs to be moved or replaced. Anything in Laravel but not in Lumen is something you'll want to look at and confirm you actually want enabled.

Side-by-side comparison of what loads by default in Lumen versus Laravel, showing Lumen&#39;s minimal opt-in stack on the left and Laravel&#39;s fully enabled default configuration on the right

Config

Lumen's config story is opt-in, end to end. If you don't call $app->configure('cache'), then config/cache.php simply doesn't load, even if the file is sitting right there on disk. That's why Lumen apps tend to have bootstrap/app.php files with a stack of $app->configure(...) calls at the top.

Laravel loads everything in config/ automatically. Every file. Always. So when you move the application over, the config story stops being "what's the smallest set I can get away with" and becomes "what should the defaults be for all of this".

Concretely, here's what changes:

Your Lumen bootstrap/app.php config calls go away.

PHP bootstrap/app.php (Lumen - before)
$app->configure('app');
$app->configure('cache');
$app->configure('database');
$app->configure('queue');

In Laravel, those four files just exist and are loaded on boot. You delete the configure() calls and copy the actual config/*.php files over.

Files Lumen didn't have, Laravel expects. A fresh Laravel install ships with config/auth.php, config/broadcasting.php, config/cors.php (or it's in bootstrap/app.php on Laravel 11), config/filesystems.php, config/hashing.php, config/logging.php, config/mail.php, config/sanctum.php, config/services.php, config/session.php, config/view.php. If you carry over your Lumen config/ folder verbatim, you'll be missing several of these. The safer move is the other direction: start from a fresh Laravel skeleton's config/, then merge your customisations from the Lumen versions into the Laravel files.

.env mostly survives. Variable names are the same: DB_HOST, QUEUE_CONNECTION, MAIL_MAILER. A few keys that Lumen never used will need values, particularly APP_KEY (Lumen often ran without one). Run php artisan key:generate early in the migration and commit the change to .env.example so anyone setting up the project gets the right shape.

The env() helper still gets cached. This trips people up. In Lumen the env() helper is fine to use anywhere; the framework doesn't cache config the way Laravel does. In Laravel, once you run php artisan config:cache, every env() call outside a config file returns null. The migration target is to use env() only inside config/*.php files and then config('services.stripe.key') everywhere else. Easy to internalise; easy to forget when you're shovelling code from one project into another.

A small operational tip: don't run config:cache until late in the migration. Production-style caching makes debugging much harder while you're moving things around, and you only really need it for benchmarking and the final smoke-test against the production build.

Testing

This is the one that bites teams who thought they were done.

Lumen ships its own test scaffolding:

PHP tests/TestCase.php (Lumen)
<?php

namespace Tests;

use Laravel\Lumen\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    public function createApplication()
    {
        return require __DIR__.'/../bootstrap/app.php';
    }
}

Laravel's looks like this:

PHP tests/TestCase.php (Laravel)
<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;
}

Different base class, different bootstrap entry. The Laravel\Lumen\Testing\TestCase doesn't ship with Laravel, so on the day you swap frameworks every single one of your tests stops compiling.

What's worse is what partially keeps working. The HTTP test helpers ($this->get('/orders'), $this->post('/orders', [...])) exist on both base classes, so a lot of tests still run. But:

  • Return types differ. Lumen's HTTP test methods return $this for chaining (the old "Lumen testing trait" style). Laravel's return a TestResponse object. So $this->get('/x')->seeJson([...]) works in Lumen, but in Laravel you'd write $this->getJson('/x')->assertJson([...]).
  • Assertion method names differ. Lumen's older see* methods (seeStatusCode, seeJson, seeJsonStructure) are not on Laravel's TestResponse. The equivalents are assertStatus, assertJson, assertJsonStructure. You can grep -r "->see" tests/ and that becomes your rename list.
  • Database refresh traits differ. Lumen had DatabaseMigrations and DatabaseTransactions traits in Laravel\Lumen\Testing. Laravel has RefreshDatabase, DatabaseMigrations, and DatabaseTransactions under Illuminate\Foundation\Testing. Imports change; behavior is similar but RefreshDatabase is the modern default and it's smarter about caching the schema between tests.

A reasonable port plan for the test suite, in order:

  1. Replace the base Tests\TestCase class to extend Laravel's.
  2. Replace any custom traits Lumen had with the Laravel equivalents.
  3. Search-and-replace ->see calls to ->assert* equivalents. The names are predictable but it's tedious.
  4. Find every $this->get(...), $this->post(...) etc. that returns a value and isn't being chained, and convert them to $response = $this->getJson(...) style.
  5. Run the suite. Expect a lot of red the first time. Most of the failures will fall into one of three buckets: missing use imports, wrong assertion names, or things that worked in Lumen because a service provider was off and now do something different in Laravel because it's on.

The last bucket is the dangerous one. A test that passed in Lumen because Auth::user() returned null (no auth provider) may now fail in Laravel because the auth provider is wired up and the test user is being resolved as someone you didn't expect. Read those failures carefully. They're often the most useful test cases for finding real behavioral drift.

A migration order that lets you back out

The trick with any framework migration is making sure each step is independently revertible. If you can't merge step 3 to production until step 7 is also done, you don't have a migration. You have a long-lived feature branch that's going to rot.

A shape that works for Lumen to Laravel:

  1. Start the new Laravel app fresh, in a new directory. Don't migrate in place. The point isn't to convert files; it's to stand up a Laravel skeleton and copy code into it one piece at a time.
  2. Port the composer.json dependencies. Keep versions, remove laravel/lumen-framework, add laravel/framework at the matching major version. Run composer install and fix whatever breaks at the dependency level before any of your code touches the new project.
  3. Port config/. Start from the fresh Laravel skeleton's config/, merge in your Lumen-specific values. Resolve .env variables. Don't run config:cache yet.
  4. Port bootstrap/app.php decisions. Every $app->withFacades(), $app->withEloquent(), $app->configure(...), $app->middleware(...) call has either a Laravel equivalent or is now a no-op because Laravel does it by default. Walk through the file line by line. Do not skim.
  5. Port routes/. Translate $router->... to Route::.... Stub the controllers as one-liners returning placeholder data. Boot the app. curl every endpoint and confirm 200s.
  6. Port app/. Controllers, models, providers, middleware, jobs, listeners. Mostly mechanical; namespaces stay the same, base classes might change slightly (controllers extend Illuminate\Routing\Controller in Laravel vs Laravel\Lumen\Routing\Controller in Lumen; the methods you used probably exist on both).
  7. Port tests/. Now your assertions actually mean something because the code under test is real.
  8. Run the full test suite. Fix the drift. This is where the work-that-looks-like-progress and the work-that-is-progress diverge. Treat every failure as a question: did the test always rely on a Lumen quirk, or did I break the behavior in the port?
  9. Smoke test against a staging environment. With the real config, real database, real queue, real cache. Lumen's defaults around session-less, stateless, file-cache-only behavior often hid issues that show up the second you put it on Redis.
  10. Run both side by side behind a load balancer for a day. Keep the Lumen app live, route 5% of traffic to Laravel, watch the error rate. If it's quiet, ramp to 50%. Then 100%. Then decommission Lumen.

The reason for steps 9 and 10 is that the kinds of bugs Lumen-to-Laravel migrations produce (config drift, missing providers, env-cache mismatches, auth resolution differences) almost never show up in tests. They show up the first time a real production request hits a route that uses a feature you forgot was disabled in the old app.

A few things that aren't migration-critical but you'll regret skipping

Pin the PHP version explicitly. Lumen historically allowed broader PHP version ranges than current Laravel. Bumping to Laravel will usually mean bumping PHP, which means rebuilding your CI images. Do that before you commit the framework swap, not after.

Reset your queue worker supervisor configs. The artisan commands are the same (queue:work, queue:listen), but the queue connection defaults can differ if you carried over a partial config/queue.php. Restart workers cleanly after the cutover.

Check your log channels. Lumen often defaulted to a single file logger. Laravel defaults to the stack channel and may write somewhere different. If your alerting depends on a specific log path, verify the new path before you turn off the old app.

Look at your Handler::report() and Handler::render() methods. Lumen's exception handler base class is Laravel\Lumen\Exceptions\Handler. Laravel's is Illuminate\Foundation\Exceptions\Handler. The method signatures and the way HTTP exception rendering works overlap, but not exactly. If you have custom exception rendering logic, particularly anything around JSON-vs-HTML response shape, port that explicitly and write a test for it.

When the migration is worth it

Not always. If your Lumen service is two endpoints, runs forever without anyone touching it, and has no new feature pressure, leave it alone. Lumen is still functional, it's just on a much slower development track than Laravel itself.

The argument for migrating is the moment you notice your team is working around the framework: hand-rolling features Laravel ships with, declining to use packages because they assume full Laravel, fighting the testing setup, or watching new hires bounce off the unusual conventions. At that point you're paying the microframework tax without getting the microframework benefit. The migration is one bounded project. The workarounds are forever.

The four areas in this piece (routes, providers, config, testing) are where the migration looks mechanical and is actually behavioral. Port them carefully, run the app behind a small percentage of real traffic, and decommission the old service only once the new one has matched its boring-day error rate for at least a week. That's it.