Simple PHP web applications without dependencies

Introduction

The World Wide Web has changed significantly since its inception. What was once just a way to share documents has now transformed into a channel to provide full-fledged applications to users around the world.

These applications are often not just a way to read text and look at picture, but provide services like instant messaging, all sorts of content editing, marketplaces and so on so forth.

As these applications became more and more complex, backend developers also transitioned towards large and complex frameworks. Consequently this move has led to issues like supply chain attacks, but this article is not about any of this. In fact, when it comes to backend development I too agree that it's better to use a featureful and battle-tested framework even if it means security becomes harder.

On the other hand, many times I've seen simple applications built on top of hundreds of third-party packages providing features developers don't even need but are used in some specific part of the platform and thus must be installed anyway. I think that's not just a waste of computing resources, but complicates the developer's job too as now there is a need to follow updates for every dependency in order to avoid unknown yet important issues.

I asked myself: can a simple and modern application be built with no dependencies while also providing enough backend security? In this article I show my experiments with the PHP language, specifically with features from version 8 onwards.

How much is simple?

Here I'm specifically talking about simple applications, but what does that mean? How do you qualify an application as simple?

There is no definitive answer as everybody has its own metrics. For the purpose of this article, a simple web application is defined in terms of the number of relations between stored data.

No matter how information is stored, from plain files to relational databases to document storage, applications always need to store some state in order to provide their service to users. Trivially, if a service can be used only by registered users, the application must store each user's access details.

As the number of stored “structures” increases, relationships between items form: in an instant messaging services, users could be part of one-to-one conversations or be part of “rooms” with more people all talking at the same time.

Here, a simple application is a service with, at most, three or four relationships between stored data, no matter how many “types” of information are stored. It's based on an indicative amount and I'm sure it is possible to write an application with few relationships yet is by no means simple, but in my experience most of the time this much is appropriate.

To make an example, a service to store and share files is a simple application: a user owns a number of virtual directories and each virtual directory owns a file. Sharing a file or a directory only requires generating a unique URI, maybe from hashing the file's contents.

What is modern?

In addition to simple, I also talk about an application being modern, but what does that mean?

Originally, files were shared across the web by providing the full path inside a “document root” directory. For example, if the document root was the path /var/www/mysite and the requested path was /home/banner.png, the server would send the contents of the file located at /var/www/mysite/home/banner.png.

This practice is very straightforward and fast and if you have a website whose only purpose is to display some text, a few images, maybe some videos and never accept submissions from visitors, it's really more than enough.

On the other hand, this approach means any file inside the document root can be accessed freely, including configurations with sensitive information such as passwords. If you have ever looked at the access logs of an HTTP server, you probably noticed all the requests for paths like /.git or /.env with varying path lengths and depths. That noise is a consequence of people mistakingly sharing secret files with the world.

In order to prevent these trivial attacks, backend software started to use virtual routing: instead of directly serving the file, each requests is sent to a single entrypoint, for example index.php. The application would then match routes with the requested path, with regular expressions or other solutions, until one of them is able to handle it. If none matches the server will respond with an error, usually a 404 Not Found.

Because resources are not located at physical paths anymore, the application can now handle dynamic URIs. The most basic example is requesting the profile page of a user given its ID: before virtual routing the request might have been to /users.php?id=123, with virtual routing it could be /users/123. It might be a trivial change, but it provides many benefits, like writing a route to handle only user profile requests, simplifying code structure for maintainability.

In addition to virtual routing, another feature of modern applications are services. This is especially true for software written using object-oriented languages, including PHP.

A service is a single “unit” with methods to operate on specific data. A user service might provide methods to extract information about profiles from a database, a security service might implement credential checking for logins, a templating service might generate human-readable views of provided values.

Implementing a service with object-oriented features is trivial: define it as a class whose methods are the allowed operations, instantiate is a needed and it's ready. Most services are plain objects, but some common patterns are really services in disguise, like Active Records.

Thanks to services it's trivial to structure the application's code according to common practices. For example in a Model View Controller structure each component can be a service in itself. The rest of the article will mostly follow this definition.

Application design

To summarize, this article will show how to use PHP 8 to implement a simple application with virtual routing and services as needed to handle requests and database access. The application will mostly follow the MVC pattern.

I already built a tech demo called Share24, a service to share plain text for 24 hours. You can check its source code too.

This article will not explain how to re-implement the demo, it will explain only the important details and show only a handful of code snippets.

The application's entrypoint

The application will process all requests going to the public/index.php file. It is usually necessary to configure the server, typically Apache, in order to redirect everything there, but how to do so depends on the hosting provider so I will omit the procedure here. Refer to what your provider offers to understand how to do it.

It is specifically placed inside the public directory in order to isolate it from the rest of the backend code. This directory can also contain other files like images, client-side scripts and styles, videos and other medias, as long as it's not code executed by the server.

The whole application will rely on classes and it will be structured so that each class is in its own file. As such, we must first ensure PHP is able to locate these files, by registering an autoload function.

spl_autoload_register(function ($class_name) {
    $root_path = dirname(__DIR__);
    $file_name = str_replace('\\' DIRECTORY_SEPARATOR, $class_name);
    $full_path = $root_path . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . $file_name . '.php';
    require_once($full_path);
});

The code is trivial: first find the actual location of the entire application on the filesystem, then replace each backslash with the directory separator symbol, then build a path inside the src directory and require the file located there. For example, the class App\Service might be loaded from /var/www/mysite/src/App/Service.php. If the file does not exist, an error is generated.

This simple autoloading forces us to put everything and everyone under the src directory, but that's good practice anyway. On the other hand this is nothing more than simple autoload to get things done, so really it can be exchanged for any other clever solution.

Once classes can be autoloaded, request processing can start.

use Core\Core;

try {
    (new Core())->run();
} catch (\Throwable $t) {
    error_log($e->getMessage());
    http_response_code(500);
}

The run method of the Core class does all the heavy lifting: request processing, virtual routing, generating responses and so on. The index.php file should really be as small as possible, defining anything else here should be reserved only for functions that must be available everywhere at any time.

The tech demo's index.php is slightly more complex in order to make development easier, but anything beyond what was shown above is optional.

The Core

The core of the application, provided by the aptly named Core class, needs to follow these steps for each request:

  1. find the route able to handle the request;
  2. execute the code to handle the route or generate an error;
  3. send the route's response back to the client.

For our simple case, routes are found by matching the request's URI with regular expression patterns using PHP's preg_match function.

$http_uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$route_item = null;
$route_components = [];

foreach ($this->routes as $name => $item) {
    $item_uri = ...; // explained later

    if (empty($item_uri)) {
        continue;
    }

    if ('/' === $item_uri && $item_uri === $http_uri) {
        $route_item = $item;
        break;
    }

    $pattern = '/^' . str_replace('/', '\\/', $item_uri) . '$/u';
    $matches = preg_match($pattern, $http_uri, $route_components, PREG_UNMATCHED_AS_NULL);
    if ($matches) {
        $route_item = $item;
        break;
    }
}

Here, the code iterates over all the known routes and does a number of checks on the URI path obtained from the $_SERVER superglobal. Some parts are omitted as they are explained later. Other than checking if the route matches, the preg_match function will also fill the $route_components array with values from regular expression groups, i.e. patterns enclosed in parentheses.

Once we have a route, the core needs to execute it. In order to do so, it will rely on PHP's reflection facilities by following these steps. The rest of the article will go into details.

  1. Obtain a \ReflectionMethod from the route;
  2. obtain the method's \ReflectionClass object;
  3. resolve any required method argument;
  4. invoke the method.

Finally, the value returned by the invoked method will be used to build the HTTP response to send back to the client. Trivially, this value is an instance of a Core\Response class to easily configure headers, cookies, the status code and the body.

Getting data out of it is trivial:

http_response_code($response->getCode());
foreach ($response->getHeaders() as $name => $value) {
    header($name . ': ' . $value, true);
}

$expiry = time() + (60 * 24 * 60 * 60); // 60 days as an example
foreach ($response->getCookies() as $name => $value) {
    $options = [
        'expires' => $expiry,
        'path' => '/',
        'httponly' => true,
        'samesite' => 'Strict',
    ];
    setcookie($name, $value, $opts);
}

print $response->getBody();

The print statement ends processing: the response body was finally sent over the network to the client and we can terminate.

Routing

How can the application define a route? Most trivially, the application can simply write all possible routes inside an array and have the core iterate over it.

This method is fine but has a number of shortcomings. Obviously, one is that every time a new route is created, the array must be manually changed to associate the pattern to match with the method to call, and that's a lot of development burden. A less noticeable but subtle issue is that whenever someone needs to check how a route works, that person must look into two files: the one defining the method and the one associating it with a route.

The same issues are present even if the array is defined in a configuration file that's parsed by the core, a solution that might be used in order to keep the core “generic” instead of dealing with application-specific details.

Thankfully, PHP 8 introduced a feature allowing developers to describe a route in the same place as its definition: attributes.

Thanks to attributes, it is now possible to define a controller class, to follow the MVC pattern, whose methods are “decorated” with routing details.

namespace Controller;

use Core\Route;
use Core\Response;

final class MyController
{
    #[Route(name: 'homepage', uri: '/', methods: ['GET'])]
    public function homepage() : Response
    {
        // code goes here

        return new Response()->setCode(200);
    }
}

Here, Core\Route is an attribute with three properties: the route name, the URI pattern and which HTTP methods are allowed. The array of methods gives the application power to stop unwanted requests with a 403 Method Not Allowed error.

The route attribute is trivial to define: simply store the given values and use defaults where appropriate.

namespace Core;

#[\Attribute(\Attribute::TARGET_METHOD)]
final class Route
{
    public function __construct(public readonly string $name,
                                public readonly string $uri,
                                public readonly array $methods = ['GET'])
    {
    }
}

Now that methods can be decorated with route details, the core needs a way to discover these attributes. Much like when executing the method associated with a route, explained later, PHP's reflection mechanism plays a key role.

The code itself is fairly verbose and mostly filled with boilerplate, so it will be omitted here, to keep things short and easy to read. Here I'll give an overview of its behaviour.

First it checks the contents of a specific directory, in this case src/Controller, iterating over every PHP file contained therein. The directory itself is not important and can potentially be made configurable, if desired.

For each file, it will try to create a \ReflectionClass instance for the class with the same name as the file, under the Controller namespace. For example, the class Controller\MyController must be defined in the file src/Controller/MyController.php and viceversa.

Then, all methods of this class are extracted as an array and iterated over: if the method has any attributes and at least one of them is a Core\Route, it will be stored in an array together with the attribute under the route's name.

The demo application has its routing component implement \ArrayAccess in order to allow expressions like $routing['homepage'], but this is optional.

Now that methods are stored, executing a route is as simple as calling invokeArgs on the stored \ReflectionMethod, for example in an expression like: $routing['homepage']['method']->invokeArgs(...).

Processing routes

Now that routes can be accessed at any time, it's time to actually execute their associated method. In particular the core will make use of the invokeArgs method, but why is that?

It's natural for routes to depend on other objects to perform some actions: a route handling a POST request needs to access the submitted data; a route fetching something from a database must first connect to the DBMS.

A developer could simply create these instances in the actual body of the method, but if any of those objects have its own dependencies then the developer will then have to instantiate them too, recursively until a “plain” object is met. That is a lot of code to write manually, maintain and it also exposes implementation details. The code might be entirely under our control, but the more it's built on “contracts” rather than implementations, the easier to maintain.

A simple solution to recursive instantiation is factories, objects whose purpose is to create instances of other objects while hiding the gory details. It's a fine solution, but as the number of dependencies increase, the code will end up littered with calls to multiple factories and it quickly becomes a mess.

Is even there a way to obtain objects with complex initializations without having to deal with manual creation or factories? The answer is actually yes and anyone familiar with the Symfony framework will immediately recognize it as the autowiring feature.

Instead of creating objects inside the method's body, they are declared in the method's signature with their type. Thus, a route handling a POST request to store something in the database might be defined as following.

#[Route(name: 'submit_something', uri: '/submit_something', methods: ['POST'])]
public function submit_something(Request $request, Database $database) : Response
{
    // code here...
}

By listing each parameter's type, the application's core can use reflection to extract information about each item and automatically instantiate it, recursively. The dependency resolver is effectively a factory, but instead of generating only one type of object, it is able to instantiate any object of a known class.

The resolveInstance method takes a fully qualified class name, obtains the __construct method, instantiates all the parameters and finally creates a new instance with the resolved arguments. Parameter resolution is simply a recursive call of resolveInstance on each \ReflectionNamedType obtained from the method's signature.

Now, when the core finds a route all it needs to do is resolve the route method's parameters and execute it with real automatically instantiated objects with the correct class.

$controller_class = $method->getDeclaringClass()->getName();
$controller_instance = $this->resolver->resolveInstance($controller_class);
$method_parameters = $this->resolver->resolveParameters($method);
$response = $method->invokeArgs($controller_instance, $method_parameters);

Now that dependencies are resolved automatically, implementing services is as easy as defining a class and declaring its use either in a route method or in a class's __construct. For example, a “repository” pattern becomes trivial to use.

final class ItemRepository
{
    public function __construct(Database $database)
    {
    }

    public function getSomeItems() : array
    {
        $values = $this->database->... // whatever code is needed
        // other code here
        return $processed;
    }
}

final class ItemController
{
    #[Route(name: 'get_items', uri: '/items')]
    public function get_items(ItemRepository $items) : Response
    {
        $values = $items->getSomeItems();
        // rest of code here
    }
}

Detour: some useful services

A quick section to lay out some useful services, namely session management and database access.

Sessions can be handled by PHP's own mechanism, but wrapping it with a service class helps maintenance, especially since the use of the service becomes explicit in the route method or constructor's signature.

The wrapper is very simple, providing methods to store or fetch values, but it's important to note that it's a lazy service: even though the object is created, no sessions are started until one of its methods are called. This is important to avoid wasting resources or unintentionally open a security hole.

Database access works the same way: the actual object does nothing until a connection is requested, when a \PDO object is created. By deferring everything to PHP's built-in class the application does not need to re-implement features like prepared statements.

However, to connect to a database the application needs to know secret credentials which cannot be hard-coded inside the application's code, especially if the code is then shared freely like the tech demo is. These values are obtained through an environment service.

Instead of relying on just real environment variables defined when the application is started, the Core\Environment service will parse a file called .env located in the application's “root” directory, where src and public are. This file follows the same syntax as environment variables defined for example in a shell, in which the name and the value are separated by an equal sign: VARIABLE_NAME=VARIABLE_VALUE.

It's common for development machines to have different database credentials than servers where applications are deployed, so in order to allow both “default” values and “overrides” for each machine, the service will merge the contents of a file called .env.local over what's inside .env. This process can be trivally extended to check for other files like .env.local.dev, .env.local.test and so on based on arbitrary heuristics, but this is left as an exercise to the reader.

This override allows .env to be commited inside a version control system like Git or Fossil with fake values, while the real credentials are kept hidden in a “local” file.

Validating forms with reflection

Forms are the way users can send data back to the server for processing. Front-end frameworks have constantly re-implemented them for inane reasons, but even after so many years they still work perfectly fine and most applications really don't need any more than a <form> element in the right place.

Of course, it's not all roses and forms still need to be secured, but by handling them the same way as models in the MVC pattern, many issues can be solved all in one place easily.

The Core\Form\Form model is the entry point to form submission and validation. It contains all fields to be sent to the server and will validate each of them. It is not important if the field was made visible to the user or not, as that's something left for the “view” part.

In particular, during instantiation the form should automatically create as much security measures as possible, like setting up an anti-CSRF token and the likes. The demo implementation shows how to configure this token, however please note that the demo is built on the assumption that there will be at most one form per page to simplify some things, and thus the code is not re-usable everywhere.

Once initialized, the Core\Form\Form class itself doesn't do much: the submitted values are stored inside Core\Request instances while constraints on these values are handled by implementations of the Core\Form\FormField interface.

Each form field is a class “decorated” by attributes expressing constraints on the value of the field. These constraints can be as simple as checking that the field is not empty for mandatory fields, but they can express complex conditions too, like ensuring the value matches certain patterns or follows a well-defined syntax.

Thanks to attributes, the code can once again leverage reflection the same way virtual routing does, providing a readable and structured way to express our intentions without scattering directives among multiple files.

foreach ($this->input_fields as $input_field) {
    $item = new \ReflectionObject($input_field);
    foreach ($item->getAttributes() as $attribute) {
        $attribute_item = $attribute->newInstance();
        if (!($attribute_item instanceof Core\Form\FormConstraint)) {
            continue;
        }

        $field_value = $request_body[$field->getName()] ?? null;
        if (!$attribute_item->validate($field_value)) {
            $field->setValid(false);
        }
     }
}

The tech demo's code is more detailed and provides more features, but the previous snippet is the fundamental process.

Now, all it takes to validate user submission is to define classes for each field used by the application and the respective constraints, which is a quite trivial task.

#[\Attribute(\Attribute::TARGET_CLASS)]
final class NonEmptyFormConstraint extends FormConstraint
{
    #[\Override]
    public function validate(mixed $value) : bool
    {
        return !empty($value);
    }
}

#[NonEmptyFormConstraint]
final class NonEmptyFormField extends AbstractFormField
{
}

$form->addInputField(new NonEmptyFormField('username'));

The tech demo does not have an AbstractFormField class and later it's shown there is no need for one, but it's used here for illustrative purposes.

HTML and Templating

All the code so far purely handles server-side processing: it takes the request, extracts relevant information from it and finally operates on this information. How can the application present the result of this process to the user?

To display “static” information, something that never changes from request to request, is very simple: just copy the structure into the response body. However, most of the time an application has to show dynamic data which can change entirely or in part according to the processed request.

The most trivial example of dynamic data is a user's profile page. A user, other than its log-in name, might want to have a different name displayed, or add a short autobiography, or add its birthday. These are all information taken from storage, like a database, and injected into the structure of the response data.

Since this is a web application this structure is most likely plain HTML, but an online service might provide other serialization methods like JSON or XML. Thus, the application must find a way to take a known structure, a template, and substitute certain patterns with the actual data taken from storage.

In “proper” frameworks, this is accomplished by providing a dedicated markup language, which is then compiled into a format for fast processing when requested, then it is cached somewhere and usually the server uses this cached file as a response. When the original file is changed, the cache is invalidated and the file compiled again.

PHP itself was born as a templating language and in fact many compilers simply produce plain PHP files as their output.

We could spend time writing a compiler for our application too… but do we really want to spend time on a compiler for an application with three database tables and four routes? It seems a lot of work for no real gain to me. Additionally, cache invalidation is the second most difficult task, after naming things. I'm not really looking forward to spend time writing reliable and robust cache-busting code.

How do we escape this conundrum? One possible solution is to actually write a file in a special markup language, copy its contents inside a PHP string, substitute the special patterns with preg_replace or equivalent, and use the result as the response. This is perfectly fine, but it has some major issues: to begin with, reading a file for every request is very slow and a simple application really has no reason to be that slow; substitution is also slow, as for each pattern it needs to scan a possibly very large string linearly and modify it; finally, it uses a lot of resources for a very inefficient operation and that's never a good thing.

A more clever approach can be found if we look at how dynamic data is usually represented, no matter if it's HTML, JSON or XML.

<div id="10" class="user-details">
  <div class="user-details-name">John Doe</div>
  <div class="user-details-birthday">1970-01-01</div>
  <ul class="user-details-posts">
    <li id="1039" class="user-details-post>Hello World</li>
    <li id="1040" class="user-details-post>About me</li>
    <li id="2392" class="user-details-post>On my carreer as a janitor</li>
  </ul>
</div>

{
  "type": "user-details",
  "name": "John Doe",
  "birthday": "1970-01-01",
  "posts": [
    "1039": "Hello World",
    "1040": "About me",
    "2392": "On my carreer as a janitor
   ]
}

Of course in actual real application the structure can be different, especially in HTML where, for example, the list of posts might be part of a completely different <div>, but fundamentally it's made of some kind of “container” whose contained elements can either be other containers or the dynamic information itself. In some cases these containers map perfectly with internal representations used by the server… and PHP comes with its own internal cache for compiled bytecode… as you probably have already guessed, the application will use PHP classes as its template engine!

Class to HTML

As bizarre as it might seem, developing a class hierarchy to generate HTML, or any other format, is actually simple.

First, let's define the HTML document that will contain everything else: according to HTML rules, the document must have a head and a body, and these two elements can then contain other items.

namespace Core\HTML;

use Core\HTML\Head;
use Core\HTML\Body;

class Document implements \Stringable
{
    public function __construct(private readonly Head $head = new Head(),
                                private readonly Body $body = new Body())
    {
    }

    public function getHead() : Head
    {
        return $this->head;
    }

    public function getBody() : Body
    {
        return $this->body;
    }

    public function __toString() : string
    {
        return '<!DOCTYPE html>' .
            '<html>' .
            (string)$this->head . (string)$this->body .
            '</html>';
    }
}

Here I'm explicitly initializing the (readonly) properties because I want to create Core\HTML\Document instances explicitly only when I want to throught the use of new, but it can be defined as an “autowired” service too. The explicit getter methods are simply how I personally prefer to approach instance or class properties and do not affect how the system works.

By implementing the \Stringable interface, the document can be automatically generated when the print statement to send the response back to the client is executed; until then, data can be handled through more efficient structures.

The Core\HTML\Head and Core\HTML\Body classes represent the <head> and <body> tags. Even though they are distinct, in the end they behave the same way: they have other tags as children, they accept attributes (not usually useful for <head>, but very much so for <body>) and web browsers treat them the same way as other HTML nodes. As such, making them inherit from a common Core\HTML\Tag class simplifies them a lot, especially since their __toString method would be the same.

namespace Core\HTML;

class Tag implements \Stringable
{
    public function __construct(protected string $name = 'div',
                                protected ?string $id = null,
                                protected array $classes = [],
                                protected array $attributes = [],
                                protected array $children = [])
    {
    }

    public function __toString() : string
    {
        $id = (empty($this->id)) ? '' : ('id="' . htmlspecialchars($this->id) . '"');

        $class = ((empty($id)) ? '' : ' ') .
            ((empty($this->classes)) ? '' : 'class="' . implode(' ', array_map('htmlspecialchars', $this->classes)) . '"');

        $attribute = (empty($class)) ? '' : ' ';
        foreach ($this->attributes as $name => $value) {
            $attribute = $attribute . ' ' . htmlspecialchars($name) . '=' . '"' . htmlspecialchars($value) . '"';
        }

        $body = implode('', array_map(function (Tag $e) {
            return (string)$e;
        }, $this->children));

        $str = '<' . htmlspecialchars($this->name) .
            ((empty($id) && empty($class) && empty($attribute)) ? '' : ' ') .
            ($id . $class . $attribute);

        $void_tags = ['area', 'base', 'br', 'col', 'embed', 'hr',
                      'img', 'input', 'link', 'meta', 'param',
                      'source', 'track', 'wbr'];

        if (in_array($this->name, $void_tags, true)) {
            $str = $str . '>';
        } else {
            $str = $str . '>' . $body . '</' . htmlspecialchars($this->name) . '>';
        }

        return $str;
    }
}

A number of getter and setter methods have been omitted for brevity, consult the tech demo's source code for more details.

The __toString method is somewhat intimidating, but it does very trivial operations. First, it creates the class attribute value by joining every member of the $this->classes array; then, the associative $this->attributes array is iterated over in order to produce name="value" pairs; the HTML element's body is created by casting every object inside the $this->children array to string, which will execute their __toString method. The $void_tags array is simply used to ensure proper HTML syntax is respected.

Now, Core\HTML\Head and Core\HTML\Body just needs to extend this new Core\HTML\Tag class and initialize themselves with anything that must always be present, <head> especially will benefit from always adding responsive tags to its children. However, the constructor contains a lot of implementation details which, especially when defining non-core classes, is never something one should deal with in object-oriented programming.

To escape from these details, the Core\HTML\Tag class just needs to define an overridable configure method (the name is not important) accepting no arguments and call it in its constructor. That way, any subclass can override a “neutral” method and be sure it can initialize itself as if it were overriding the constructor.

final class Head extends Tag
{
    #[\Override]
    protected function configure() : static
    {
        parent::configure();
        $this->name = 'head';

        $meta_charset = new Tag('meta');
        $meta_viewport = new Tag('meta');

        $meta_charset->addAttribute('charset', 'utf-8');
        $meta_viewport->addAttribute('name', 'viewport')
            ->addAttribute('content', 'width=device-width,initial-scale=1')
            ;

        $this->appendChild($meta_charset)->appendChild($meta_viewport);

        return $this;
    }
}

final class Body extends Tag
{
    #[\Override]
    protected function configure() : static
    {
        parent::configure();
        $this->name = 'body';
        return $this;
    }
}

Using the same process, Core\HTML\Document can be subclassed too, in order to create more specific templates that can be further subclassed as needed.

final class BaseDocument extends Document
{
    #[\Override]
    protected function configure() : static
    {
        $this->title = new Tag('title');
        $this->title->appendChild(new Text('Generic Template Page'));
        return $this;
    }
}

The Core\HTML\Text class is, admittedly, a bit of a hack to allow plain text as the contents of an element, but it ensures every child is always a subtype of Tag instead of Tag|string|\Stringable, which is quite the looser combination and requires careful handling. It might not look elegant, but the type safety it provides more than make it up for it.

However, this still misses the important point: how can we provide dynamic values to these “templates”? The configure method is called during instantiation with the new keyword and thus cannot be used to obtain values from the outside world. Additionally, a controller might need to access some element of the template for processing before being able to pass some data in; a simple example is when forms are defined inside the document rather than getting created by the controller and given to the document later. The tech demo does this, for instance.

The most simple way is to simply define another method in the base Core\HTML\Document class and let subclasses override it to handle dynamic values.

public function with(array $values) : static
{
    // code to inject the contents of the $value array inside the document
    return $this;
}

Now, injecting values becomes an explicit and fairly readable operation, which is always a good thing for maintainability.

#[Route(name: 'submit_something', uri: '/submit_something', methods: ['GET', 'POST'])]
public function submit_something(Request $request) : Response
{
    $document = new SubmitSomethingDocument();

    $some_value = null;

    $form = $document->getForm();
    if ($form->validate($request) && $form->isSubmitted()) {
        $some_value = $request->getSubmittedValue('some_value');
    }

    $response = new Response();
    $response->setBody($document->with([
        'some_value' => $some_value,
    ]));

    return $response;
}

Closing

This article shows very little code, most of which is stripped down to the bare minimum and might not even work, in order to be illustrative without being too distracting with administrative details.

It really only scratches the surface by only explaining essential points, despite the impressive length. The tech demo is a full-fledged application from which you can learn all the other details that have been omitted here.

In the end writing the core part, the one upon which applications can be built, the framework if you will, takes about one or two days of work to implement entirely from the ground up. It's a lot of work but not as much as one might think when hearing the words “no dependencies” or “from scratch”.

Nonetheless, even though it's a fun learning project, I still suggest relying on a bigger and better developed framework like Symfony or Laravel or whatever is popular nowadays, unless you know, a priori that the set up explained here is more than enough and that you can patch it up whenever a hole opens.

Post Scriptum: What about an ORM?

When talking about accessing the database I quickly described a wrapper service class, Core\Database and left the heavy work to PHP's \PDO. Some readers might wonder why I never took a detour to create an ORM with no dependencies.

While it would certainly be an interesting learning experience, for the scope of this project an entire ORM is overkill.

Sure, it's very nice to say, for example, $post = $posts->get(1) and obtain a PHP object with methods to operate on the fetched data, and then have something like $post->save() or $posts->save($post) to transparently store it again without dealing with DBMS details.

However, at the start of this article I stressed that the entire approach is only suitable for applications with two, three, maybe four tables and only a handful of relationships between them. With a set that small, writing raw SQL…

SELECT a.id,b.name,c.address_string FROM users AS a
LEFT JOIN user_details AS b ON a.id = b.id
LEFT JOIN user_addresses AS c ON a.id = c.id
WHERE a.id = 18

…is really not any more intimidating than writing a Doctrine ORM class.

In terms of DBMS agnosticism… I never really saw applications moving from one DBMS to another and to be honest, I think for the small size of the typical application this “framework” is aimed at, re-writing the SQL is not the gargantuan task that would require the protection of an ORM.

In short, with only a few tables and a small number of JOIN operations, an ORM is useless and there's no need to spend time writing one. You can write something similar to an ORM for your application, maybe something similar to Symfony's repository pattern, but it will be specific to your application.