Modern PHP Without a Framework

Kevin Smith ·

I’ve got a challenge for you. The next time you start a new project, try not using a PHP framework.

Now, this isn’t an anti-framework screed. Neither is it a promotion of not-invented-here thinking. After all, we’re going to be using some packages written by several framework developers in this tutorial. I’ve got nothing but great respect for the innovation going on in that space.

This isn’t about them. This is about you. It’s about giving yourself the opportunity to grow as a developer.

Perhaps the biggest benefit you’ll find working without a framework is the wealth of knowledge about what’s going on under the hood. You get to see exactly what’s happening without relying on the framework’s magic to take care of things for you in a way that you can’t debug and don’t really understand.

It’s quite possible your next job will not grant the luxury of starting a greenfield project with your framework of choice. The reality is that most high-value, business-critical PHP jobs involve existing applications. And whether that application is built in a framework currently enjoying popular support like Laravel or Symfony, a framework from days gone by like CodeIgniter or FuelPHP, or even the depressingly widespread legacy PHP application employing an “include-oriented architecture”, building without a framework now will better prepare you to take on any PHP project in the future.

In the past, it was an uphill battle to build without a framework because some kind of system had to interpret and route HTTP requests, send HTTP responses, and manage dependencies. The lack of industry standards necessarily meant that, at the very least, those components of a framework were tightly coupled. If you didn’t start with a framework, you’d end up building one yourself.

But today, thanks to all the autoloading and interoperability work done by PHP-FIG, building without a framework doesn’t mean building it all by yourself. There are so many excellent, interoperable packages from a wide range of vendors. Pulling it all together is easier than you think!

# PHP, How Does it Work?

Before we get into anything else, it’s important to understand how PHP applications interact with the outside world.

PHP runs server-side applications in a request/response cycle. Every interaction with your app—whether it’s from the browser, the command line, or a REST API—comes into the app as a request. When that request is received, the app is booted up, it processes the request to generate a response, the response is emitted back to the client that made the request, and the app shuts down. That happens with every interaction.

# The Front Controller

Armed with that knowledge, we’ll kick things off with the front controller. The front controller is the PHP file that handles every request for your app. It’s the first PHP file a request hits on its way into your app, and (essentially) the last PHP file a response runs through on its way out of your app.

Let’s use the classic Hello, world! example served up by PHP’s built-in web server just to make sure we’ve got everything wired up correctly. If you haven’t already done so, be sure you have PHP 7.2 or newer installed in your environment.

Create a project directory with a public directory in it, and then inside it create index.php with the following code.

<?php
declare(strict_types=1);

echo 'Hello, world!';

Note that we’re declaring strict typing here—which is something you should do at the top of every PHP file in your app—because type hints are important for debugging and clearly communicating intent to developers that come behind you.

Navigate to your project directory using a command-line tool (like Terminal on macOS) and start PHP’s built-in web server.

php -S localhost:8080 -t public/

Now load http://localhost:8080/ in your browser. See “Hello, world!” without any errors?

Awesome. Now let’s move on to the meat and potatoes!

# Autoloading and Third-Party Packages

When you first started with PHP, you may have used includes or requires statements throughout your app to bring in functionality or configuration from other PHP files. In general, we want to avoid that because it makes it much harder for a human to follow the code path and understand where dependencies lie. That makes debugging a real nightmare.

The solution is autoloading. Autoloading just means that when your application needs to use a class, PHP knows where to look for it and automatically loads it when it’s called for. It’s been available since PHP 5, but its usage really started picking up steam with the introduction of PSR-0 (the autoloading standard that has since been superseded by PSR-4).

We could go through the rigamarole of writing our own autoloader, but since we’re going to use Composer to manage third-party dependencies and it already includes a perfectly serviceable autoloader, let’s just use that one.

Make sure you’ve got Composer installed on your system. Then setup Composer for this project.

composer init

This will take you through an interactive guide to generate your composer.json configuration file. Once that’s done, open it in an editor and add the autoload field so it looks something like this. (This makes sure the autoloader knows where to look to find our app’s classes.)

{
    "name": "kevinsmith/no-framework",
    "description": "An example of a modern PHP application bootstrapped without a framework.",
    "type": "project",
    "require": {},
    "autoload": {
        "psr-4": {
            "ExampleApp\\": "src/"
        }
    }
}

Now install composer for this project, which brings in any dependencies (if we had any yet) and sets up the autoloader for us.

composer install

Update public/index.php to bring in the autoloader. Ideally this is one of the few “include” statements you’ll use in your app.

<?php
declare(strict_types=1);

require_once dirname(__DIR__) . '/vendor/autoload.php';
echo 'Hello, world!';

If you reload your app in the browser, you won’t see anything different. The autoloader is there, it’s just not doing any heavy lifting for us. Let’s move the Hello, world! example to an autoloaded class to see how that works.

Create a new directory from the project root called src, and inside it add HelloWorld.php with the following code.

<?php
declare(strict_types=1);

namespace ExampleApp;

class HelloWorld
{
    public function announce(): void
    {
        echo 'Hello, autoloaded world!';
    }
}

Now in public/index.php, replace the echo statement with a call to the announce method on the HelloWorld class.

// ...

require_once dirname(__DIR__) . '/vendor/autoload.php';

$helloWorld = new \ExampleApp\HelloWorld();$helloWorld->announce();

Reload your app in the browser to see the new message!

# What is Dependency Injection?

Dependency Injection is a programming technique where each dependency is provided to a class that requires it rather than that class reaching outside itself to get the information or functionality it needs.

For example, let’s say a class method in your application needs to read from the database. For that, you’ll need a database connection. A common technique is to create a new connection with credentials retrieved from the global space.

class AwesomeClass
{
    public function doSomethingAwesome()
    {
        $dbConnection = new \PDO(
            "{$_ENV['type']}:host={$_ENV['host']};dbname={$_ENV['name']}",
            $_ENV['user'],
            $_ENV['pass']
        );
        
        // Make magic happen with $dbConnection
    }
}

But that’s messy, it puts responsibilities in this method that don’t really belong here—creating a new DB connection object, retrieving credentials, and handling any issues that might come up if the connection fails—and it leads to a lot of code duplication across the app. If you ever try to unit test this class, you’ll find that you can’t. This class is tightly coupled to both the app environment and the database.

Instead, why not be as clear as possible about what your class needs to begin with? Let’s just require that the PDO object be injected into the class in the first place.

class AwesomeClass
{
    private $dbConnection;     public function __construct(\PDO $dbConnection)    {        $this->dbConnection = $dbConnection;    }     public function doSomethingAwesome()
    {        
        // Make magic happen with $this->dbConnection    }
}

That’s a lot cleaner, easier to understand, and less prone to bugs. Through type hinting and dependency injection, the method declares exactly what it needs to do its job and gets it without calling up an external dependency from inside itself. When it comes to unit testing, we’re in great shape to mock up a database connection and pass it in.

Now a dependency injection container is a tool that you wrap around your entire application to handle the job of creating and injecting those dependencies. The container is not required to be able to use the technique of dependency injection, but it helps considerably as your application grows and becomes more complex.

We’ll use one of the most popular DI containers for PHP: the aptly named PHP-DI. (Worth noting that its docs have a different way of explaining dependency injection that may be helpful to some readers.)

# The Dependency Injection Container

Now that we’ve got Composer set up, it’s pretty painless to install PHP-DI. Head back over to your command line to install it.

composer require php-di/php-di

Update public/index.php to configure and build the container.

// ...

require_once dirname(__DIR__) . '/vendor/autoload.php';

$containerBuilder = new \DI\ContainerBuilder();$containerBuilder->useAutowiring(false);$containerBuilder->useAttributes(false);$containerBuilder->addDefinitions([    \ExampleApp\HelloWorld::class => \DI\create(\ExampleApp\HelloWorld::class)]); $container = $containerBuilder->build(); $helloWorld = $container->get(\ExampleApp\HelloWorld::class);$helloWorld->announce();

Nothing earth-shattering going on yet. It’s still a simple example with everything in one file so that it’s easy to see what’s going on.

So far, we’re just configuring the container so that we must explicitly declare dependencies (rather than using autowiring or attributes), and retrieving the HelloWorld object from the container.

A brief aside: Autowiring is a great feature offered by many DI containers to help minimize mindless configuration, but we’re disabling it in this tutorial to maximize learning. Explicitly configuring all dependencies here will give you a much better understanding for what an autowiring DI container is doing under the hood.

Let’s make things even easier to read by importing namespaces where we can.

<?php
declare(strict_types=1);

use DI\ContainerBuilder;use ExampleApp\HelloWorld;use function DI\create;
require_once dirname(__DIR__) . '/vendor/autoload.php';

$containerBuilder = new ContainerBuilder();$containerBuilder->useAutowiring(false);
$containerBuilder->useAttributes(false);
$containerBuilder->addDefinitions([
    HelloWorld::class => create(HelloWorld::class)]);

$container = $containerBuilder->build();

$helloWorld = $container->get(HelloWorld::class);$helloWorld->announce();

As it stands right now, this just looks like a lot of extra fuss to do what we were already doing before.

Not to worry, the container comes in handy when we add a few other tools to help direct the request through our application. They’ll use the container to load the right classes when and where we need them.

# Middleware

If you imagine your application like an onion with requests coming in from the outside, going to the center of the onion, and going back out as responses, then middleware is each layer of the onion receiving the request, potentially doing something with that request, and either passing it on to the layer below it or creating a response and sending it back up to the layer above it. (That can happen if the middleware is checking for a certain condition that the request does not satisfy, like requesting a non-existent route.)

If the request makes it all the way through, the app will process it and turn it into a response, and each middleware in reverse order will receive the response, potentially modify it, and pass it on to the next middleware.

A sampling of use cases where middleware can shine:

  • Debugging issues in development
  • Gracefully handling exceptions in production
  • Rate-limiting incoming requests
  • Responding to incoming requests for unsupported media types
  • Handling CORS
  • Routing requests to the appropriate handler class

Is middleware the only way to implement tools for handling these sorts of things? Not at all. But middleware implementations make the request/response cycle that much clearer for you, which means debugging is that much easier and development is that much quicker.

We’ll take advantage of middleware for the last use case listed above: routing.

# Routing

A router uses information from an incoming request to figure out which class should handle it. (e.g. The URI /products/purple-dress/medium should be handled by ProductDetails::class with purple-dress and medium passed in as arguments.)

For our example app, we’ll use the popular FastRoute router through a PSR-15 compatible middleware implementation.

# The Middleware Dispatcher

To get our app to work with the FastRoute middleware—and any other middleware we install—we’ll need a middleware dispatcher.

PSR-15 is a middleware standard that defines interfaces for both middleware and dispatchers (called “request handlers” in the spec), allowing interoperability amongst a wide variety of middleware and dispatchers. We just need to choose a PSR-15 compatible dispatcher, and we can be sure it’ll work with any PSR-15 compatible middleware.

Let’s install Relay as the dispatcher.

composer require relay/relay

And since the PSR-15 middleware spec requires implementations to pass along PSR-7 compatible HTTP messages, we’ll use Laminas Diactoros as our PSR-7 implementation.

composer require laminas/laminas-diactoros

Let’s get Relay ready to accept middleware.

// ...

use DI\ContainerBuilder;
use ExampleApp\HelloWorld;
use Relay\Relay;use Laminas\Diactoros\ServerRequestFactory;use function DI\create;

// ...

$container = $containerBuilder->build();

$middlewareQueue = []; $requestHandler = new Relay($middlewareQueue);$requestHandler->handle(ServerRequestFactory::fromGlobals());

We’re using ServerRequestFactory::fromGlobals() on line 16 to pull together all the information necessary to create a new Request and hand it off to Relay. This is where Request enters our middleware stack.

Now let’s add the FastRoute and request handler middleware. (FastRoute determines if a request is valid and can actually be handled by the application, and the request handler sends Request to the handler configured for that route in the routes definition.)

composer require middlewares/fast-route middlewares/request-handler

And define the route to our Hello, world! handler class. We’ll use a /hello route here to show that a route other than the base URI works.

// ...

use DI\ContainerBuilder;
use ExampleApp\HelloWorld;
use FastRoute\RouteCollector;use Middlewares\FastRoute;use Middlewares\RequestHandler;use Relay\Relay;
use Laminas\Diactoros\ServerRequestFactory;
use function DI\create;
use function FastRoute\simpleDispatcher;
// ...

$container = $containerBuilder->build();

$routes = simpleDispatcher(function (RouteCollector $r) {    $r->get('/hello', HelloWorld::class);}); $middlewareQueue[] = new FastRoute($routes);$middlewareQueue[] = new RequestHandler();
$requestHandler = new Relay($middlewareQueue);
$requestHandler->handle(ServerRequestFactory::fromGlobals());

For this to work, you’ll also need to update HelloWorld to make it an invokable class, meaning that the class can be called as if it were a function.

// ...

class HelloWorld
{
    public function __invoke(): void    {
        echo 'Hello, autoloaded world!';
        exit;    }
}

(Note the added exit; in the __invoke() magic method. We’ll get to that in a second—just didn’t want you to miss it.)

Now load http://localhost:8080/hello and bask in your success!

# The Glue that Holds it All Together

The astute reader will quickly notice that although we’re still going to the trouble of configuring and building the DI container, it’s not actually doing anything for us. The dispatcher and middleware can do their job without it.

So when does it come into play?

Well, what if—as would nearly always be the case in a real application—the HelloWorld class has a dependency?

Let’s introduce a trivial dependency and see what happens.

// ...

class HelloWorld
{
    private $foo;
    public function __construct(string $foo)    {        $this->foo = $foo;    }
    public function __invoke(): void
    {
        echo "Hello, {$this->foo} world!";        exit;
    }
}

Reload the browser, and…

Yikes.

Look at that ArgumentCountError.

That’s happening because now HelloWorld requires a string to be injected at construction time in order to do its job, and it’s been left hanging. This is where the container helps out.

Let’s define that dependency in the container and pass the container to RequestHandler to resolve it.

// ...

use Laminas\Diactoros\ServerRequestFactory;
use function DI\create;
use function DI\get;use function FastRoute\simpleDispatcher;

// ...

$containerBuilder->addDefinitions([
    HelloWorld::class => create(HelloWorld::class)        ->constructor(get('Foo')),    'Foo' => 'bar']);

$container = $containerBuilder->build();

// ...

$middlewareQueue[] = new FastRoute($routes);
$middlewareQueue[] = new RequestHandler($container);
$requestHandler = new Relay($middlewareQueue);
$requestHandler->handle(ServerRequestFactory::fromGlobals());

Voilà! You should see “Hello, bar world!” when you reload the browser.

# Properly Sending Responses

Remember earlier when I made a point of mentioning the exit statement sitting in HelloWorld?

Well that’s a quick and dirty way to make sure we get a simple response while we’re building things out, but it’s not the best way to send output to the browser. Such a crude technique gives HelloWorld the additional job of responding—which should really be the responsibility of another class—it overcomplicates sending proper headers and status codes, and it shuts down the app without giving the middleware that comes after HelloWorld a chance to run.

Remember, each middleware has the opportunity to modify Request on its way into our app and (in reverse order) modify the response on its way out of the app. In addition to the common interface for Request, PSR-7 also defines the structure for another HTTP message that will help us out on the back half of that cycle: Response. (If you want to really get into the nuts and bolts, read all about HTTP messages and what makes PSR-7 Request and Response standards so great.)

Update HelloWorld to return a Response.

// ...

namespace ExampleApp;

use Psr\Http\Message\ResponseInterface;
class HelloWorld
{
    private $foo;

    private $response;
    public function __construct(
        string $foo,
        ResponseInterface $response    ) {
        $this->foo = $foo;
        $this->response = $response;    }

    public function __invoke(): ResponseInterface    {        $response = $this->response->withHeader('Content-Type', 'text/html');        $response->getBody()            ->write("<html><head></head><body>Hello, {$this->foo} world!</body></html>");         return $response;    }}

And update the container definition to provide HelloWorld with a fresh Response object.

// ...

use Middlewares\RequestHandler;
use Relay\Relay;
use Laminas\Diactoros\Response;use Laminas\Diactoros\ServerRequestFactory;
use function DI\create;

// ...

$containerBuilder->addDefinitions([
    HelloWorld::class => create(HelloWorld::class)
        ->constructor(get('Foo'), get('Response')),    'Foo' => 'bar',    'Response' => function() {        return new Response();    },]);

$container = $containerBuilder->build();

// ...

If you reload the page now though, you’ll get a blank screen. Our app is returning a proper Response object from the middleware dispatcher, but then… what?

Doing nothing with it, that’s what.

We need one more tool to wrap things up: an emitter. An emitter sits between your app and the web server (Apache, nginx, etc.) that will send your response to the client that initiated the request. It essentially takes the Response object and translates it into instructions that a server API can understand.

Let’s pull in Narrowspark’s HTTP Emitter.

composer require narrowspark/http-emitter

Update public/index.php to receive Response from the dispatcher and pass it off to the emitter.

// ...

use Middlewares\FastRoute;
use Middlewares\RequestHandler;
use Narrowspark\HttpEmitter\SapiEmitter;use Relay\Relay;
use Laminas\Diactoros\Response;

// ...

$requestHandler = new Relay($middlewareQueue);
$response = $requestHandler->handle(ServerRequestFactory::fromGlobals()); $emitter = new SapiEmitter();return $emitter->emit($response);

Reload your browser, and we’re back in business! And this time with a far more robust way of handling responses.

Line 15 in the code example above is where the request/response cycle ends in our app and the web server takes over.

Note that for the sake of the example, the emitter configuration we’re using here is very straightforward. Though it can be a bit more complex, a real app should be configured to automatically use a streaming emitter for large downloads. The documentation for Laminas’s HTTP Request Handler Runner, a combo PSR-15 dispatcher and PSR-7 emitter, discusses one interesting way to accomplish this.

# Wrapping Up

So there you have it. With just 44 lines and the help of several widely-used, thoroughly-tested, reliably interoperable components, we have the bootstrap for a modern PHP application. It’s compliant with PSR-4, PSR-7, PSR-11, and PSR-15, which means you can use your pick of any of a broad range of vendors’ implementations of those standards for your HTTP messages, DI container, middleware, and middleware dispatcher.

We dove deep into some of the technology and reasoning behind our decisions, but I hope you can see just how simple it is to bootstrap a new application without the cruft of a framework. Perhaps more importantly, I hope you’re better prepared to work these techniques into an existing application when the need arises.

Have a look at the GitHub repo for this example app, and feel free to fork it or download it.

If you’re looking for more great sources for high-quality, decoupled packages, I wholeheartedly recommend checking out Aura, The League of Extraordinary Packages, Symfony components, Laminas components, Paragon Initiative’s security-focused libraries, and this list of PSR-15 middleware.

If you were to use this example code in production, you’d probably want to break the routes and container definitions out into their own files so that they’re easier to maintain as the complexity grows. I’d also recommend implementing EmitterStack for smart handling of file downloads and other large responses.

Be sure to read the follow-up: “Didn’t you just build your own framework?”

Any questions, confusion, or suggestions for improvement? Gimme a shout.


Updated on Jan 12, 2019: Updated all libraries, and bumped the PHP requirement to 7.2, the minimum actively supported version of PHP. The latest version of Zend Diactoros no longer includes an emitter, so the Properly Sending Responses section was updated to use Narrowspark’s emitter instead. It was an absolutely seamless replacement, too. Really highlights how wonderful those PSRs can be!

Updated on March 24, 2019: Since originally publishing this post, I’ve been persuaded to rethink my stance on autowiring. The benefits are undeniable:

  • Development is generally faster, and with a much lower “cost” to adding new dependencies to a class, there’s one less argument against breaking up responsibilities into separate classes.
  • Autowiring generally only works with type-hinted parameters, which naturally discourages primitive obsession.
  • Comprehensibility is improved because container configuration cruft is gone, leaving you with only meaningful configuration.

As with any “magic”, autowiring should be carefully considered before enabling, but modern DI containers handle it quite sensibly. For example, PHP-DI only autowires concrete classes, so there’s no possibility of ambiguity, and Symfony’s service container will throw an unavoidable exception in dev environments if there’s any ambiguity about which dependency should be provided.

Updated on Dec 31, 2022: Zend was renamed Laminas some time back, so the tutorial now uses the equivalent Laminas packages instead.

Updated on Apr 9, 2024: PHP-DI's Annotations were replaced by PHP's native Attributes in version 7. Thanks to surreal30 for the PR to keep the example repo updated.