Building Applications Without a Full Framework
Assemble a small, working PHP application out of independent, single-purpose Composer packages — a router, a PSR-7/PSR-17 HTTP message implementation, a PSR-15 middleware dispatcher, and a DI container — instead of reaching for a full-stack framework, and understand the real trade-offs of doing so.
Every PHP developer eventually reaches for Laravel or Symfony, and for good reason: full frameworks bundle a router, an ORM, a templating engine, a validation layer, a console, testing scaffolding, and years of accumulated convention into one cohesive package. But a framework is also a very large dependency with its own opinions, its own upgrade cadence, and its own learning curve, and not every problem needs one. A webhook receiver that validates a signature and writes a row to a database, a tiny internal API with three endpoints, or a library that needs an HTTP entrypoint for its documentation examples often doesn't need Doctrine, Blade, Artisan, and a service container preloaded with two hundred bindings — it needs a router and something to turn a request into a response. This lesson walks through building exactly that: a real, working application composed entirely from independent PSR-standard packages, wired together by hand in a single front controller, with no framework underneath it at all.
Why Go Framework-Agnostic
"Framework-agnostic" doesn't mean "no structure" — it means the structure is assembled from small, independently-versioned packages rather than inherited wholesale from one large framework package. The PHP ecosystem makes this practical because of PSR (PHP Standards Recommendations): PSR-7 standardizes HTTP message objects (requests and responses as immutable value objects), PSR-17 standardizes factories for creating those objects, PSR-15 standardizes HTTP middleware and request handlers, and PSR-11 standardizes the interface a dependency injection container exposes. Because these are interfaces, not implementations, you can pick a PSR-7 implementation from one vendor, a router from another, and a container from a third, and they all speak the same contracts to each other.
The strongest case for this approach is control and footprint. A framework-agnostic stack has no auto-loaded service providers you didn't write, no configuration cache to worry about invalidating, no ORM pulling in query builders and migration tooling you don't use, and — often materially — a much smaller number of classes autoloaded and files touched on every request, which matters for cold-start-sensitive environments like serverless functions or for embedding an HTTP layer inside a library that shouldn't drag a framework in as a dependency. You also gain the freedom to swap any single piece: if the router turns out to be too slow, you replace only the router, because nothing else in the stack knows or cares which router you used.
The honest case against it is assembly cost and lost convention. A framework gives you an opinionated answer to dozens of questions — how database connections are configured, how validation errors are formatted, how a 404 becomes a JSON response versus an HTML page, where a job queue lives — for free. Going framework-agnostic means you answer every one of those questions yourself, and a team of five people can quietly drift into five slightly different answers without a shared convention to anchor them. There's also more glue code: someone has to write the front controller, wire the container, decide how errors propagate out of middleware, and document all of it, because none of that ships pre-written the way it does in Laravel's bootstrap/app.php or Symfony's kernel.
In practice, this approach is most valuable for small, well-bounded applications (microservices, webhook handlers, internal tools), for libraries that need to expose an HTTP interface without imposing a framework choice on consumers, and for engineers who want to deeply understand what a framework is actually doing under its abstractions before they use one. It is a poor fit for large, long-lived applications with big teams, where the shared conventions a framework enforces are worth far more than the flexibility you give up.
• Framework-agnostic = composed from independent PSR-standard packages, not a single monolithic framework dependency. • PSR-7 (HTTP messages), PSR-17 (HTTP factories), PSR-15 (middleware/handlers), PSR-11 (container interface) are the contracts that let pieces from different vendors interoperate. • Pros: smaller footprint, full control, easy to swap individual pieces, no unused framework machinery. • Cons: more glue code to write and maintain, no built-in conventions, more architectural decisions land on your team. • Best fit: small services, webhook/API endpoints, HTTP-capable libraries, and learning what frameworks abstract away.
The Four Building Blocks
The first block is a router: something that maps an incoming HTTP method and path to a handler. nikic/fast-route is a minimal, extremely fast router built around a compiled regular-expression dispatch table; it has no opinion about anything except "given a method and a path, which handler and route parameters match?" league/route builds on top of similar ideas but adds PSR-15 middleware support and PSR-7 integration directly, which makes it a common choice for exactly this kind of stack. Either way, the router's only job is dispatch — it does not know what a controller is, how dependencies get resolved, or how a response gets sent.
The second block is a PSR-7/PSR-17 implementation, which provides the actual Request, Response, Stream, and Uri objects plus factories to build them. nyholm/psr7 is the de facto standard choice here: it's small, fast, fully PSR-7 and PSR-17 compliant, and has essentially no dependencies of its own. Without this piece you have no standard vocabulary for 'the incoming request' or 'the response to send' — every other package in the stack is written against these interfaces, not against superglobals like $_SERVER or $_POST directly.
The third block is a PSR-15 middleware dispatcher — the piece that runs a request through an ordered pipeline of middleware (for cross-cutting concerns like authentication, CORS headers, logging, or error handling) before it reaches the final request handler that produces the actual response. relay/relay and middlewares/* packages, or the dispatching built into league/route, fill this role. Middleware in PSR-15 is deliberately simple: each middleware receives a request and a handler for 'the rest of the pipeline', and either returns a response itself (short-circuiting) or calls the next handler and optionally modifies its response on the way back out.
The fourth block is a PSR-11 dependency injection container — something that can construct objects (typically controllers) and satisfy their constructor dependencies automatically or via explicit configuration. PHP-DI uses reflection and optional autowiring to build objects with minimal configuration; league/container is a lighter, more explicit container favoring manual bindings. The container's job is narrow: given a class name, produce a fully-wired instance of it, resolving and instantiating whatever it depends on along the way.
• Router: nikic/fast-route (bare dispatch) or league/route (dispatch + PSR-15 + PSR-7 built in). • HTTP messages: nyholm/psr7 — PSR-7 Request/Response objects plus PSR-17 factories. • Middleware dispatcher: relay/relay, middlewares/*, or league/route's built-in pipeline. • DI container: PHP-DI (autowiring via reflection) or league/container (explicit bindings). • Each package only knows the PSR interfaces of its neighbors — never a concrete class from another vendor's package.
Gluing It Together: the Front Controller
Every request into a framework-agnostic application passes through exactly one PHP file — the front controller, conventionally public/index.php — which is the only file the web server is configured to serve directly (everything else lives outside the public web root). This file has one job: bootstrap the pieces (container, router, middleware pipeline), build a PSR-7 ServerRequest from the real PHP superglobals, run it through the pipeline, and emit the resulting PSR-7 Response back to the browser using something like laminas-httphandlerrunner's SapiEmitter, or a small hand-rolled function that sets the status line, headers, and body via header() and echo.
The wiring is almost always the same shape regardless of which concrete packages you pick: build the container first (because both the router's handler-resolution and any middleware may need it), register routes against the router (mapping method+path to a controller class and method, not to an already-instantiated object — instantiation is deferred to the container so constructor dependencies get resolved lazily, per-request), build the middleware pipeline with the router's dispatch as the innermost handler, then create the PSR-7 request from globals, run it through the pipeline, and emit the response.
Error handling deserves explicit thought here because a framework normally handles it for you: an uncaught exception thrown deep in a controller needs to become a proper HTTP response (500 in production with a generic body, a detailed trace in development) rather than a raw PHP fatal error dumped as HTML. The idiomatic way to handle this in a PSR-15 stack is an error-handling middleware placed outermost in the pipeline — it wraps the call to the next handler in a try/catch and converts any exception into a well-formed PSR-7 response, which keeps error handling as just another swappable middleware rather than special-cased framework code.
• One front controller (public/index.php) is the sole entrypoint; everything else lives outside the web root. • Boot order: build container → register routes (as class+method strings, not instances) → build middleware pipeline → build request from globals → dispatch → emit response. • Instantiate controllers lazily through the container so their dependencies are resolved per-request, not eagerly at boot. • An outermost error-handling middleware converts uncaught exceptions into proper HTTP responses instead of raw PHP fatals. • Emitting a PSR-7 response means writing its status line, headers, and body via header()/echo (or a helper like laminas-httphandlerrunner).
Trade-offs in Practice
The clearest way to see the trade-off is to count what you'd get for free from a framework and compare it to what you now own. Routing, request/response objects, and basic middleware are roughly a wash — a framework's router isn't meaningfully more capable than fast-route or league/route for typical use. Where you feel the absence most is in things a framework normally standardizes silently: consistent JSON error envelopes, request validation with automatic 422 responses, environment-based configuration loading, database connection pooling and migrations, session handling, CSRF protection for form-based apps, and a console for one-off maintenance commands. Every one of those is available as its own Composer package (vlucas/phpdotenv for env config, respect/validation or symfony/validator standalone for validation, doctrine/dbal for a database layer, and so on), but you are the one choosing, installing, and wiring each of them, and you are the one who has to keep them working together as versions change.
The other side of the trade-off is genuinely positive: because nothing is bundled, upgrading is granular. A framework major version bump can force changes across your whole codebase at once (a new Symfony or Laravel major version often deprecates or removes APIs your controllers, service providers, and views all touch); a framework-agnostic stack lets you upgrade the router today and the DI container next quarter, each in isolation, because they don't know about each other. You also end up with an application whose total dependency tree you can actually read end to end, which matters when you need to audit exactly what code runs in production or when you're optimizing cold-start time in a serverless deployment.
The practical recommendation most experienced PHP teams land on is: reach for a framework by default for anything that will grow into a real application with multiple developers over years, because the convention and batteries-included tooling pay for themselves quickly. Reach for a framework-agnostic stack deliberately, for something small and well-bounded, where you already know exactly which pieces you need and can name why a full framework's extra weight and opinions wouldn't earn their keep. Treat the framework-agnostic exercise, at minimum, as a way to understand what a framework is doing for you — every Laravel or Symfony developer benefits from having built a request through a router and a container by hand at least once.
• You gain: granular upgrades, a fully auditable dependency tree, minimal footprint, freedom to swap any single piece. • You lose: free validation, config loading, DB layer, sessions, CSRF, console tooling — all become separate packages you choose and wire yourself. • A framework's biggest value is convention across a team, which a hand-assembled stack does not provide automatically. • Default to a framework for large, long-lived, multi-developer applications; go framework-agnostic deliberately for small, well-scoped ones.
Code Examples
Each dependency is small, narrowly scoped, and independently versioned: a router, a PSR-7 implementation plus a helper to build a ServerRequest from PHP superglobals, a PSR-15 dispatcher, an autowiring DI container, and a small emitter to send a PSR-7 response back to the browser. Nothing here is a framework — remove any one line and the rest still work, just with a gap to fill.
This is the whole application shell: a container for autowiring, a compiled fast-route dispatcher for matching, a one-item error-handling pipeline built with Relay, and nyholm/psr7 objects flowing through all of it. No framework class appears anywhere — every moving part is a small, independently-installed package, and the only 'framework' is the roughly 60 lines of glue in this file.
$r->addRoute('GET', '/users/{id}', new UserController());
$r->addRoute('GET', '/users/{id}', [UserController::class, 'show']);
Registering an already-instantiated controller forces every route in the file to build its controller eagerly at boot time, even for routes that never get hit on a given request, and bypasses the container's dependency resolution entirely. Registering a class name/method pair lets the container construct the controller lazily, only for the one route that actually matched, with its real dependencies wired in.
// no error handling middleware — an exception in a controller becomes a raw PHP fatal error page
$pipeline = new Relay([new ErrorHandlerMiddleware(), $routerHandler]);
Without an outermost error-handling middleware, any uncaught exception (a failed database query, a missing array key) turns into PHP's default fatal-error output — often leaking a full stack trace and file paths to the client. A framework does this for you invisibly; in a framework-agnostic stack you must add it yourself as the first middleware in the pipeline.
header('Content-Type: application/json'); echo json_encode($data); exit;
return new Response(200, ['Content-Type' => 'application/json'], json_encode($data));
Calling header() and echo directly inside a controller couples it to the PHP SAPI and makes it untestable without a real HTTP request/response cycle. Returning a PSR-7 Response object keeps the controller a pure function of request-in, response-out, and lets a single emitter at the very edge of the app be the only place that talks to header()/echo.
What is the primary purpose of PSR-7 in a framework-agnostic PHP stack? A. It defines how routes are matched to controllers B. It standardizes HTTP request and response objects as immutable value objects so different packages can interoperate C. It is a dependency injection container specification D. It replaces Composer for dependency management
B. It standardizes HTTP request and response objects as immutable value objects so different packages can interoperate — PSR-7 standardizes the shape of HTTP message objects (Request, Response, Stream, Uri). Because every PSR-7-compliant package speaks the same object contracts, a router from one vendor and an HTTP client from another can hand PSR-7 objects back and forth without either knowing the other's concrete implementation.
In the front-controller example, why are routes registered as [ControllerClass::class, 'method'] rather than as already-constructed controller instances? A. FastRoute cannot accept objects at all, only strings B. It lets the DI container construct the matched controller lazily, per-request, resolving its real dependencies only when that specific route is hit C. It is required by the PSR-15 specification D. It makes the routes load faster because strings are cached automatically
B. It lets the DI container construct the matched controller lazily, per-request, resolving its real dependencies only when that specific route is hit — Registering a class/method pair defers instantiation to the container, which only builds the one controller for the route that actually matched the incoming request — avoiding eager construction of every controller in the route table on every request, and letting constructor dependencies be resolved through autowiring.
Which of the following is a genuine disadvantage of a framework-agnostic stack compared to a full framework like Laravel or Symfony? A. It cannot use Composer packages B. It is impossible to add middleware to it C. Cross-cutting concerns like validation, config loading, and CSRF protection are not bundled and must be chosen, installed, and wired individually D. PSR-7 objects are mutable, causing hard-to-find bugs
C. Cross-cutting concerns like validation, config loading, and CSRF protection are not bundled and must be chosen, installed, and wired individually — A framework bundles solutions for validation, environment configuration, database access, sessions, CSRF, and more, all pre-integrated. In a framework-agnostic stack you must pick a separate package for each concern and keep them working together yourself as the application and its dependencies evolve.
Why does the example place an ErrorHandlerMiddleware as the outermost layer of the PSR-15 pipeline rather than inside the router handler? A. Outermost middleware runs slightly faster due to PHP's call stack optimization B. PSR-15 requires exactly one middleware and it must handle errors C. Placed outermost, it wraps the call to every inner handler in a try/catch, so any exception thrown anywhere further in — including inside a controller — is caught and converted into a proper HTTP response D. It has no effect on error handling; errors are always handled by PHP automatically
C. Placed outermost, it wraps the call to every inner handler in a try/catch, so any exception thrown anywhere further in — including inside a controller — is caught and converted into a proper HTTP response — Because each PSR-15 middleware wraps the call to the next handler, an outermost error-handling middleware's try/catch surrounds the entire rest of the pipeline, so it catches exceptions from routing, from resolving a controller through the container, and from the controller's own logic, converting all of them into a well-formed response.
{
"require": {
"php": "^8.2",
"nikic/fast-route": "^1.3",
"nyholm/psr7": "^1.8",
"nyholm/psr7-server": "^1.1",
"relay/relay": "^2.2",
"php-di/php-di": "^7.0",
"laminas/laminas-httphandlerrunner": "^2.7"
},
"autoload": {
"psr-4": { "App\\": "src/" }
}
}<?php
// public/index.php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use DI\ContainerBuilder;
use FastRoute\RouteCollector;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Relay\Relay;
use function FastRoute\simpleDispatcher;
// 1. Build the DI container (autowiring via reflection).
$container = (new ContainerBuilder())->build();
// 2. Register routes as [ControllerClass, methodName] — never instantiated eagerly.
$dispatcher = simpleDispatcher(function (RouteCollector $r) {
$r->addRoute('GET', '/users/{id:\d+}', [App\Controller\UserController::class, 'show']);
$r->addRoute('POST', '/users', [App\Controller\UserController::class, 'create']);
});
// 3. The router becomes the innermost PSR-15 request handler.
final class RouterHandler implements RequestHandlerInterface
{
public function __construct(
private $dispatcher,
private $container
) {}
public function h
…