The go-to resource for learning PHP, Laravel, Symfony, and your dependencies.

Slim Framework 3 to Slim 4 Migration Guide


In the Serengeti, wildebeest undertake an annual migration spanning hundreds of miles—a journey shaped by rainfall patterns, terrain, and predator avoidance. The herd’s survival depends not just on reaching new grazing grounds, but on knowing when to move, which routes remain viable, and how to navigate river crossings that change with each season. Older matriarchs remember past droughts and successful routes; without that accumulated knowledge, the herd may follow paths that lead to dry waterholes or dangerous crossings.

Migrating from Slim Framework 3 to Slim 4 presents similar challenges. You’re navigating a changing landscape where old patterns—middleware execution order, response handling, dependency injection—no longer work the same way. The “water sources” you relied on (Slim 3’s global $app object, Pimple container, custom middleware signatures) have shifted. Without understanding the new terrain—PSR-7 request/response immutability, PSR-15 middleware contracts, PSR-11 container integration—you might waste effort on approaches that lead to dead ends.

Strictly speaking, this migration isn’t merely a dependency update; it’s an architectural mindset shift. The good news: Slim 4’s adherence to PHP-FIG standards means your application gains interoperability with the broader ecosystem. The challenge: you must rebuild core infrastructure—bootstrapping, middleware pipelines, error handling—while keeping your business logic intact.

In this guide, we’ll walk the migration path together—from assessment through implementation, testing, and troubleshooting. We’ll provide concrete code examples, discuss trade-offs, and help you decide when migration makes sense versus when staying on Slim 3 is the wiser choice.

Prerequisites

Before beginning your migration, ensure you have:

  • PHP 7.4 or later (Slim 4 requires PHP 7.4+)
  • Composer for dependency management
  • Basic understanding of PSR-7 (HTTP message interfaces)
  • Familiarity with dependency injection concepts
  • Existing Slim 3 application to migrate

If you’re starting a fresh project, consider using Slim 4 directly rather than migrating.

When to Migrate (and When Not To)

Should you migrate your Slim 3 application, or would you be better served staying put? This isn’t a simple yes-or-no question. Let’s examine the trade-offs.

Benefits of migrating to Slim 4:

  • Standards compliance: Your application becomes PSR-7/PSR-11/PSR-15 compliant, making it easier to integrate third-party middleware and components from other frameworks.
  • Future-proofing: Slim 3 is in maintenance mode; Slim 4 receives active development and aligns with modern PHP practices.
  • Cleaner architecture: The factory pattern and explicit dependencies make your application’s structure more transparent.
  • Better testing: PSR-7’s immutable request/response objects simplify test doubles and reduce side effects.

Costs and risks of migration:

  • Rewrite effort: Middleware, route handlers, and bootstrap code require substantial changes—typically 20-40% of your codebase.
  • Testing burden: You’ll need comprehensive tests to verify behavior post-migration; without them, regressions are likely.
  • Learning curve: Your team must understand PSR standards and Slim 4’s different execution model.
  • Temporary divergence: During migration, you’re maintaining two code paths if you need to keep the old version running.

When migration makes sense:

  • Your application has a long maintenance horizon (2+ years).
  • You rely heavily on third-party PSR-15 middleware you want to adopt.
  • Your team already understands PSR standards or is willing to learn them.
  • You have good test coverage (>70% of critical paths).
  • You’re planning other architectural improvements that align with Slim 4’s patterns.

When staying on Slim 3 makes sense:

  • The application is small, stable, and short-lived (planned decommission in <12 months).
  • You have extensive custom middleware that would require complete rewrites with uncertain benefit.
  • Your deadlines are tight and migration would compromise feature delivery.
  • You lack automated tests and manual testing would be prohibitively expensive.
  • Your team is deeply invested in Slim 3 patterns and migration would significantly impact productivity.

Of course, these are guidelines—you’ll need to assess your specific context. If you’re uncertain, consider migrating a small, non-critical sub-application first as a proof of concept.

High-Level Migration Steps

When we migrate a Slim 3 application to Slim 4, we follow a logical sequence—each step builds on the previous one. You’ll start with dependencies, then work through bootstrap configuration, middleware conversion, route updates, DI container integration, and finally error handling. Here’s the general order we recommend:

  1. Update composer.json for Slim 4 + PSR-7.
  2. Refactor index.php with AppFactory.
  3. Convert middleware to PSR-15 (note: LIFO order differs from Slim 3).
  4. Update route handlers to return ResponseInterface.
  5. Integrate a PSR-11 container (typically PHP-DI).
  6. Add ErrorMiddleware; migrate tests.

Of course, you may find that certain steps overlap in practice—especially testing, which should occur incrementally rather than only at the end. Though we’ve numbered these steps sequentially, you’ll often iterate between them as issues emerge during your migration journey.

Detailed Steps

1. Composer Dependencies

Let’s start with the first step: updating your composer.json file. When you migrate to Slim 4, you’ll need the core framework itself plus a PSR-7 implementation (Slim provides slim/psr7, though you can substitute others like nyholm/psr7 if you prefer).

Slim 3 (for reference):

{
    "require": {
        "slim/slim": "^3.0"
    }
}

Slim 4 (required):

{
    "require": {
        "slim/slim": "^4.11",
        "slim/psr7": "^1.2"
    }
}

After updating the file, run the update command:

$ composer update

The output will typically show package downloads and dependency resolution. For example, you might see:

Loading composer repositories with package information
Updating dependencies (including require-dev)      
Package operations: 3 installs, 0 updates, 0 removals
  - Installing slim/slim (4.11.0)
  - Installing slim/psr7 (1.2.0)
  - Installing psr/http-message (1.0)
Writing lock file

Of course, your exact version numbers may vary depending on what’s available when you run this command—the example above shows specific versions at the time of writing.

This is a good time to review breaking changes in not just Slim itself, but any other dependencies that might be affected. You’ll notice that Composer typically updates multiple packages; we recommend examining the changelog for any that could impact your application. Generally speaking, you’ll want to check the Slim 4 upgrade guide and any libraries that interact closely with the HTTP layer.

2. Bootstrap (index.php)

When you work with Slim 4, you’ll discover that it introduces AppFactory as the entry point for application creation—this factory pattern enables PSR-11 container integration from the start. The bootstrap process in Slim 4 differs substantially from Slim 3: you must explicitly add routing middleware and error middleware.

Slim 3 bootstrap (for comparison):

<?php
require 'vendor/autoload.php';

$app = new Slim\\App;  // Direct instantiation
// add middleware, routes here
$app->run();

Slim 4 bootstrap:

<?php
use Slim\\Factory\\AppFactory;
use Slim\\Middleware\\ErrorMiddleware;

require __DIR__.'/../vendor/autoload.php';

// Create application via factory
$app = AppFactory::create();

// Add routing middleware (required for route dispatching)
$app->addRoutingMiddleware();

// Add error middleware (typically last)
$errorMiddleware = $app->addErrorMiddleware(
    displayErrorDetails: true,   // Set to false in production
    logErrors: true,             // Log errors to stderr/file
    logErrorDetails: true        // Include stack traces in logs
);

// Add your middleware and routes here
$app->run();

The factory pattern gives you more control—you can optionally inject a custom PSR-7 server or PSR-11 container before calling create(). Though it might seem like extra boilerplate, this explicitness makes the application’s dependencies clearer. Note that addErrorMiddleware() should be called after your routes and other middleware; this ensures error handling is the last middleware in the pipeline.

If you need to pass display options dynamically, you can also configure the error middleware separately:

$errorMiddleware = $app->getErrorMiddleware();
$errorMiddleware->setDefaultErrorHandler(new YourCustomHandler());

3. Middleware (PSR-15)

Slim 4 requires middleware to implement the PSR-15 standard—a community-driven specification for HTTP server middleware. This adoption improves interoperability: you can use PSR-15 middleware from other frameworks (like Mezzio or laminas-mvc) without adapter code. Of course, this standardization comes with a behavioral change: Slim 4 processes middleware in last-in-first-out (LIFO) order, whereas Slim 3 used first-in-first-out (FIFO). This difference can affect authentication, logging, session handling, and other cross-cutting concerns.

Strictly speaking, the LIFO order means the last middleware you add wraps around the first—so the outermost “before” logic executes first, while the innermost “after” logic executes last. One may wonder: why LIFO? PSR-15 defines that the process() method calls $handler->handle($request) to continue the chain; this naturally builds the pipeline in reverse order as each middleware receives a handler that represents all later-added middleware.

Slim 3 middleware (FIFO, pre-PSR-15):

$app->add(function ($req, $res, $next) {
    // "Before" logic - runs first
    $res->getBody()->write('Before middleware');
    $res = $next($req, $res);  // Pass to next middleware/route
    // "After" logic - runs last
    $res->getBody()->write('After middleware');
    return $res;
});

Slim 4 middleware (LIFO, PSR-15):

use Psr\\Http\\Message\\ServerRequestInterface as Request;
use Psr\\Http\\Message\\ResponseInterface as Response;
use Psr\\Http\\Server\\RequestHandlerInterface;

$app->add(function (Request $req, RequestHandlerInterface $handler): Response {
    // "Before" logic - runs first even though added later
    $response = $handler->handle($req);  // Continue to inner middleware/route
    // "After" logic - runs last
    $response->getBody()->write('After middleware');
    return $response;
});

Alternatively, use a class-based approach—often clearer for complex middleware:

use Psr\\Http\\Server\\MiddlewareInterface;
use Psr\\Http\\Message\\ServerRequestInterface as Request;
use Psr\\Http\\Message\\ResponseInterface as Response;

class LogMiddleware implements MiddlewareInterface
{
    public function process(Request $req, RequestHandlerInterface $handler): Response
    {
        error_log('Request: ' . $req->getMethod() . ' ' . $req->getUri());
        $response = $handler->handle($req);
        error_log('Response: ' . $response->getStatusCode());
        return $response;
    }
}

$app->add(new LogMiddleware());

Though the LIFO change can be surprising at first, it aligns Slim with other PSR-15 implementations. To manage order explicitly, you can group middleware or add them in the reverse order you’d use in Slim 3. Testing is essential—verify that authentication, logging, and other sequential concerns behave as expected.

4. Routes

In Slim 4, route handlers must accept a ServerRequestInterface (type hinted as Request) and return a ResponseInterface. This strict adherence to PSR-7 ensures compatibility with the broader PHP ecosystem but requires careful attention to return values. One common pitfall is forgetting to return the response object—this triggers a RuntimeException with a message like “Handler must return a Psr\Http\Message\ResponseInterface object.”

Slim 3 route handler (for reference):

$app->get('/hello/{name}', function ($req, $res, array $args) {
    // In Slim 3, the response is passed by reference
    $res->getBody()->write("Hello, {$args['name']}");
    return $res;
});

Slim 4 route handler (PSR-7 compliant):

use Psr\\Http\\Message\\ServerRequestInterface as Request;
use Psr\\Http\\Message\\ResponseInterface as Response;

$app->get('/hello/{name}', function (Request $req, Response $res, array $args): Response {
    $res->getBody()->write("Hello, {$args['name']}");
    return $res;  // Explicit return required
});

You can also create responses via the Response factory if you need to construct a new response without modifying the incoming one:

use Psr\\Http\\Message\\ResponseInterface as Response;

$app->get('/json', function (Request $req, array $args): Response {
    $data = ['status' => 'ok', 'timestamp' => time()];
    $payload = json_encode($data);
    
    $response = new \Slim\\Psr7\\Response();  // Or your PSR-7 implementation
    $response->getBody()->write($payload);
    return $response
        ->withHeader('Content-Type', 'application/json')
        ->withStatus(200);
});

Though it’s possible to modify the provided $res object, many developers prefer returning new response instances—this aligns with PSR-7’s immutable design philosophy (immutability methods like withHeader() return new objects). Of course, the choice depends on your coding style; both approaches work as long as you always return a ResponseInterface at the end.

5. DI Container

Slim 4 drops Pimple in favor of PSR-11 agnosticism. This means you can use any PSR-11 compatible container, though you’ll need to wire it up explicitly. Let’s examine the major options.

Option 1: PHP-DI (Recommended for Most Cases)

PHP-DI is the de facto standard for Slim 4 applications. It’s feature-rich, well-maintained, and integrates seamlessly.

$ composer require php-di/php-di
use DI\\Container;
use Slim\\Factory\\AppFactory;

$container = new Container();
AppFactory::setContainer($container);
$app = AppFactory::create();

$container->set('logger', fn() => new Monolog\\Logger('app'));
$container->set('config', fn() => require 'config.php');

You can then inject dependencies via $app->getContainer()->get('logger') or, preferably, through autowiring in constructors:

class UserController
{
    public function __construct(
        private LoggerInterface $logger,
        private Config $config
    ) {}
    
    public function list(Request $request, Response $response): Response
    {
        $this->logger->info('Listing users');
        // ...
    }
}

Option 2: Symfony Container

If you’re already using Symfony components or need advanced compilation features, Symfony’s container works well:

$ composer require symfony/dependency-injection
use Symfony\\Component\\DependencyInjection\\ContainerBuilder;
use Symfony\\Component\\DependencyInjection\\Reference;
use Slim\\Factory\\AppFactory;

$container = new ContainerBuilder();
$container->register('logger', Monolog\\Logger::class)
          ->addArgument(['channel' => 'app']);
$container->compile();

AppFactory::setContainer($container);
$app = AppFactory::create();

Symfony’s container requires more configuration but offers powerful optimization through compilation and lazy services.

Option 3: Pimple with Adapter

If you have extensive Pimple definitions you can’t immediately rewrite, consider a PSR-11 adapter. The pimple/pimple package itself doesn’t implement PSR-11, but you can wrap it:

$ composer require pimple/pimple
use Pimple\\Container as PimpleContainer;
use Slim\\Factory\\AppFactory;
use function Pimple\\Container;

class PimplePsr11Adapter implements ContainerInterface
{
    private $pimple;
    
    public function __construct(PimpleContainer $pimple)
    {
        $this->pimple = $pimple;
    }
    
    public function get($id)
    {
        return $this->pimple[$id];
    }
    
    public function has($id)
    {
        return isset($this->pimple[$id]);
    }
}

$pimple = new PimpleContainer();
$pimple['logger'] = fn() => new Monolog\\Logger('app');

$container = new PimplePsr11Adapter($pimple);
AppFactory::setContainer($container);
$app = AppFactory::create();

Of course, this adapter approach adds an extra layer. Over time, you’ll likely want to migrate directly to a PSR-11-native container.

Option 4: Aura.Di

Aura.Di offers a different philosophy—explicit configuration with no magic—and some developers prefer its clarity:

$ composer require aura/di
use Aura\\Di\\Container;
use Aura\\Di\\Factory;
use Aura\\Di\\Resolver\\Lazy;
use Slim\\Factory\\AppFactory;

$container = new Container(new Factory(new Lazy()));
$container->set('logger', Monolog\\Logger::class)
          ->addArgument(['channel' => 'app']);

AppFactory::setContainer($container);
$app = AppFactory::create();

Though Aura.DI is perfectly capable, PHP-DI’s autowiring and widespread adoption make it the path of least resistance for most Slim 4 migrations.

Which Should You Choose?

Generally speaking, we recommend PHP-DI for most migrations—it’s the container the Slim community has standardized on, documentation is extensive, and autowiring reduces boilerplate. That said, if you’re already invested in Symfony components or have specific performance requirements, Symfony Container is an excellent alternative. Pimple adapter makes sense only as a temporary bridge during migration; though it might seem like the easiest path, you’re adding technical debt that you’ll eventually pay down.

Container Configuration Approaches

Regardless of which container you choose, you’ll need to decide how to define services:

  • Inline definitions: Simple $container->set() calls in your bootstrap (fine for small apps)
  • Separate configuration files: Return arrays or PHP files that define services (better for larger apps)
  • Auto-registration: PHP-DI’s $container->autoRegister() for automatic class discovery (convenient but less explicit)

We recommend starting with inline definitions during migration, then refactoring to separate configuration as your application grows. Though it’s tempting to auto-register everything for convenience, the explicitness of manual registration helps you understand your dependency graph during this critical transition period.

6. Error Handling

Add last:

$errorMiddleware = $app->addErrorMiddleware(displayErrorDetails: true, logErrors: true, logErrorDetails: true);

Custom: Extend ErrorMiddleware, override processError().

Common Pitfalls and Testing

Pitfalls:

  • Middleware order: LIFO breaks auth/logging—reverse adds or use groups.
  • No auto-response: Forgetting return $response → 500s.
  • Container mismatches: Pimple adapter fails PSR-11—switch to PHP-DI.
  • PSR-7 body: Streams not rewindable—clone for multiple reads.

Testing Migration: Use slim/php-httptest or PHPUnit:

use Slim\\Factory\\AppFactory;
use Slim\\Testing\\Request;
use Slim\\Testing\\Response;

$app = AppFactory::create();
$req = new Request('GET', '/hello/world');
$res = $app->handle($req);
$this->assertStringContainsString('Hello, world', (string)$res->getBody());

Run full suite post-migration; mock PSR-7 objects.

Verification and Troubleshooting

Let’s verify your migration succeeded and address common issues you might encounter.

Verification Checklist

Run these commands to confirm your application is functioning correctly:

# Run your test suite (if you have one)
$ composer test
# or
$ ./vendor/bin/phpunit

Expected output: All tests passing. If migration is complete but you have no tests yet, now is the time to add them—especially for your core routes.

# Check that all Slim 3 classes have been replaced
$ grep -r '\\Slim\\\\App' .
# Should return no results
# Test a critical route manually
$ curl -i http://localhost:8080/your-route
# Should return 200 with expected content

Common Errors and Solutions

Error: “Handler must return a Psr\Http\Message\ResponseInterface object”

This occurs when a route handler doesn’t explicitly return a response. Slim 4 doesn’t auto-return the modified response object like Slim 3 did.

// Wrong - missing return
$app->get('/test', function (Request $request, Response $response) {
    $response->getBody()->write('Hello');
    // Forgot return $response
});

// Correct
$app->get('/test', function (Request $request, Response $response): Response {
    $response->getBody()->write('Hello');
    return $response;  // Explicit return required
});

Error: “Call to undefined method Slim\Http\Response::write()”

In Slim 4 with slim/psr7, the Response object uses PSR-7 stream interfaces. You must write to the body stream:

// Wrong - assuming write() method
$response->write('Hello');  // Fatal error

// Correct
$response->getBody()->write('Hello');
return $response;

Middleware Order Issues

If authentication or logging middleware isn’t executing in the expected order, remember: Slim 4 uses LIFO (last-in-first-out). The middleware you add last executes first in the pipeline.

To verify order, add logging to each middleware:

$app->add(function (Request $request, RequestHandlerInterface $handler): Response {
    error_log('Middleware A: before');
    $response = $handler->handle($request);
    error_log('Middleware A: after');
    return $response;
});

$app->add(function (Request $request, RequestHandlerInterface $handler): Response {
    error_log('Middleware B: before');  // This logs FIRST
    $response = $handler->handle($request);
    error_log('Middleware B: after');   // This logs LAST
    return $response;
});

If the order is wrong, reverse the order you call $app->add().

Container Resolution Failures

“Service not found” errors typically mean your container configuration isn’t loaded or the service isn’t defined.

  • Verify you called AppFactory::setContainer($container) before AppFactory::create()
  • Check that your container definitions execute before the request is handled
  • For PHP-DI, ensure definitions are correct: $container->set('service', fn() => new Service())
  • If using autowiring, ensure your classes have valid constructor type hints

PSR-7 Stream Issues: Body Already Consumed

If you see errors about stream position or “body already written,” remember PSR-7 streams are positioned at the end after writing. To read body content later (e.g., in middleware for logging), rewind first:

$body = $request->getBody();
$contents = $body->getContents();  // Returns empty if already read

$body->rewind();  // Reset position to start
$contents = $body->getContents();  // Now works

Alternatively, clone the request for parallel operations:

$requestForLogging = clone $request;
$body = $requestForLogging->getBody();
// Read without affecting original request stream

When You’re Stuck

If you’ve tried the solutions above and still face issues:

  1. Check the Slim 4 upgrade guide at https://www.slimframework.com/docs/v4/
  2. Search GitHub issues for your error message plus “slim 4”
  3. Create a minimal reproduction—strip your app down to the failing piece
  4. Ask the community on the Slim Framework Discord or Stack Overflow with your minimal example

Of course, some issues stem from third-party packages that haven’t caught up to Slim 4. In those cases, you may need to find alternatives or temporarily fork and adapt the package. Though this isn’t ideal, it’s a reality of framework migrations—we’ve seen it with many PHP ecosystem transitions, from PHP 5 to 7, and now from Slim 3 to 4. The key is isolating the incompatible component and evaluating your options.

Alternatives and When Not to Migrate

Consider Laminas (ex-ZF) for MVC/features, or Symfony for enterprise. Don’t migrate if: small app, tight deadlines, heavy custom middleware (rewrite cost high), or non-PSR ecosystem. Slim 4 suits micro-apps needing standards—though larger apps may outgrow it.

We hope this equips you—experiment in a branch first.

Sponsored by Durable Programming

Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.

Hire Durable Programming