slim/slim-skeleton
Official Slim 4 skeleton application: a minimal starting point for building PHP microservices and APIs, with PSR-7 HTTP handling, routing, middleware, dependency injection, and Twig templates preconfigured.
composer create-project slim/slim-skeleton my-api gives you a working PSR-7 application in under a minute — no Artisan, no service providers, no Eloquent. Think of it as the scaffold you'd get if Laravel stopped at "router + container + middleware" and handed the rest to you.
First three commands:
composer create-project slim/slim-skeleton my-api
cd my-api
composer start # php -S localhost:8080 -t public public/index.php
curl localhost:8080/hello/world
Where to look first, in this order:
public/index.php (or app/bootstrap.php in newer revisions) — the whole bootstrap is ~40 lines: build PHP-DI container → load settings.php → dependencies.php → repositories.php → create app → register middleware → register routes → handle request → emit response. Read it once and you understand the entire lifecycle.app/routes.php — returns a callable receiving Slim\App; three demo routes (/, /hello/{name}, /users).app/settings.php — namespaced config array (logger, db, displayErrorDetails) fed into App\Application\Settings\Settings.app/dependencies.php — where you bind services (the closest thing to a service provider).app/Application/Actions/Action.php — the base class every action extends; it gives you respondWithData(), respondWithError(), getFormData(), resolveArg().First real use case: clone the shape of ListUsersAction / ViewUserAction and the UserRepository interface + InMemoryUserRepository implementation. That's the whole "vertical slice" idiom the skeleton is teaching: Domain interface → Infrastructure implementation → Action → Route → DI binding in repositories.php. Swap the in-memory repo for a PDO/Doctrine one and you have a real endpoint.
Single-action controllers everywhere. Actions are invokable classes resolved by PHP-DI. Constructor-inject whatever you need and extend Action for the response helpers:
final class CreateOrderAction extends Action
{
public function __construct(
LoggerInterface $logger,
private OrderRepository $orders,
) {
parent::__construct($logger);
}
protected function action(): Response
{
$data = $this->getFormData(); // merged body/query
$order = $this->orders->create($data['sku']);
return $this->respondWithData(['id' => $order->id], 201);
}
}
Interface bindings live in repositories.php. PHP-DI autowires concrete classes, but any interface (your repository ports, mailers, clients) must be explicitly mapped:
return function (ContainerBuilder $containerBuilder) {
$containerBuilder->addDefinitions([
UserRepository::class => \DI\autowire(InMemoryUserRepository::class),
OrderRepository::class => \DI\autowire(PdoOrderRepository::class),
]);
};
Group and name routes for anything beyond toy size. RouteCollectorProxy + ->setName() + ->add() mirrors Laravel's Route::group()->middleware():
$app->group('/api/v1', function (RouteCollectorProxy $group) {
$group->get('/users', ListUsersAction::class)->setName('users.list');
$group->get('/users/{id:[0-9]+}', ViewUserAction::class);
$group->post('/users', CreateUserAction::class);
})->add(AuthMiddleware::class);
$app->get('/users/{id}', ViewUserAction::class)->setName('users.view');
Middleware is PSR-15, so anything you own is portable — a JWT middleware here works unchanged in Mezzio or Laminas. Register global middleware in app/middleware.php; note the skeleton uses addBodyParsingMiddleware() so getParsedBody() returns decoded JSON/form bodies (Slim 4 no longer does this implicitly for JSON).
Config injection. Never read getenv() inside an action. Inject SettingsInterface and read a namespace:
$stripeKey = $this->settings->get('stripe')['secret'];
Testing. The skeleton's PHPUnit base sets up the app for you; test through the front controller rather than mocking:
$app = $this->getAppInstance();
$request = $this->createRequest('GET', '/users')->withHeader('Accept', 'application/json');
$response = $app->handle($request);
$this->assertSame(200, $response->getStatusCode());
$this->assertJsonStringEqualsJsonString('{"data":[...]}', (string) $response->getBody());
Console tasks (if your revision ships bin/console) use Symfony Console — that's your Artisan substitute for cron jobs, imports, and queue drains.
composer create-project copies a snapshot and drops VCS history. You will never composer update the skeleton itself; the last tagged release is from late 2022, so audit slim/slim, php-di/php-di, and phpunit versions against your PHP target (PHPUnit 9 → 10 is a config rewrite) before starting.ErrorMiddleware is registered last in public/index.php — it must be the outermost layer to catch exceptions from everything else. If you add CORS after it, your 500s lose their headers.repositories.php — nothing to do with your constructor.$response->withHeader(...) returns a new object; forgetting $response = $response->withHeader(...) is the classic silent no-op. Same for $request->withAttribute().$response->getBody()->write(...) mutates the stream; respondWithData() already handles the rewind. Rolling your own JSON response without rewind() yields empty bodies in some SAPI setups.getenv() is unreliable under PHP-FPM. Skeleton settings read getenv('APP_ENV'); with FPM's default clear_env = on, those variables are invisible. Either flip clear_env or add vlucas/phpdotenv and load it at the very top of public/index.php, before settings.php is required — order matters.displayErrorDetails must be false in production. It's driven by APP_ENV === 'development' and controls both whoops-style output and the ShutdownHandler.getParsedBody() returns null for text/plain and for JSON if addBodyParsingMiddleware() isn't registered. Validate the body yourself; the skeleton ships no validator — bring respect/validation or hand-rolled checks.resolveArg() returns strings. Route placeholders are always strings; cast before passing to a repository or you'll get subtle type-mismatch bugs with strict types.$app->handle() manually (tests, custom entrypoint) and skip addRoutingMiddleware(), unmatched routes throw HttpNotFoundException instead of rendering a 404.public/. Never point nginx/Apache at the project root; app/ and vendor/ must stay outside it. composer start gets this right — your production vhost must too.composer dump-autoload after adding new namespaces. PSR-4 maps App\ → app/; new top-level namespaces need a composer.json edit plus a dump.symfony/mailer, firebase/php-jwt, league/container-style extras — this is where a Slim project's real cost lives.$containerBuilder->enableCompilation(__DIR__ . '/../var/cache')) — it's disabled by default in the skeleton's public/index.php with a if (false) stub. Clear that cache directory on every deploy.How can I help you explore Laravel packages today?