Why I built Suphle, an opinionated PHP framework, in 2022

Among all the new PHP projects you’d expect to see in 2022/23, I doubt another PHP framework is one of them. In fact, you probably already have your favorite one serving all your needs.

So I’m not here to tell you why you should use my framework. Rather, this is the story of what inspired me to build it. At the same time, I’ll share some reasons I think it’s worth your time.

Objectives

Working with many different PHP frameworks, I witnessed first hand the amount of damage and technical debt that can accumulate in the absence of certain early decisions in the application architecture.

I’ve listed these problems below but the TL;DR is that left with no experience in a field, developing with a framework can get complicated and ugly.

Most of them were in the initial blueprint. Few were included for convenience over the course of development. Here goes:

  • Internal feature release and archiving features
  • Breaking unchanged parts of the code by modifying dependencies cuz No clearcut dependency chain, and certainly, no integration tests
  • Requiring a full stack developer to work on our UIs (before they were ported to SPAs)
  • Entire pages crashing because of an error affecting an insignificant page segment/data node
  • Waiting for negative customer feedback before knowing something went wrong, then wrangling error logs
  • Sacrificing man hours after giving up on SSR. A front end dev was hired. The back end had to be duplicated into an API with slightly diverging functionality.
  • Chasing and duplicating state and errors between the SPA and back end, for the sole purpose of a SPA-ey feel/fidelity
  • Cluelessness when our callback URLs broke in transit
  • API documentation, testing, breaking clients thanks to indiscriminate updates since there was no versioning
  • Irresponsible practices such as requests without validators, fetching all database columns, improper or no model authorisation, dumping whatever we had nowhere else to put in middlewares, gigantic images ending up at the server, models without factories, stray entities floating about when their owner model gets deleted, not guarding negative integers from sneaking in from user input, I could go on
  • Corrupted data when operations separated by logic, that should’ve been inserted together gets broken in between
  • Gnarly merge conflicts among just a handful contributors

Some of them may be age-old problems you don’t consider big deals anymore. Suphle was built to solve them by making significant changes to traditional application architecture, with the hope of helping out those who encounter some of those issues, prevent those you’re unaware of, and add additional cherries on top.

How it’s similar

In a broader sense, if you’re coming from a framework written in another language, some features there parallel what is obtainable in Suphle:

  • NestJS: Modules, @transaction
  • Spring Boot: Circular dependencies, decorators, interface auto-wiring, component/service-specific classes, @transactional
  • Rust: Macros, Result
  • Phoenix: Livewire

It may interest you to know that some of these best practices were only found to intersect after Suphle was mostly complete rather than a premeditated attempt to build a chimera of widely acclaimed functionality. That is why in Suphle, their implementation details differ. For instance, Suphle’s modules are wired/built differently. The rest of the documentation goes into thorough detail about how that, as well as other implementations you’re used to were improved upon.

Perhaps, the most significant change new Suphle developers will find is in connecting the route patterns to a coordinator. Coordinators evolved from controllers, and I will explore three components briefly. Solutions to most of the problems listed above are already covered in their respective chapters of the documentation. Considering it’s yet to be officially published, you may have to host the repo itself on your local environment.

Modules

If you’re not familiar with software modules in development terms, they are folders containing all code required to sustain a domain in your application. Each module is expected to expose meta files enabling it depend or be depended upon by other modules. Modules can be created by hand, but it’s far more convenient to use the command,

php suphle modules:create template_source new_module_name --destination_path=some/path

They can be created as you progress through development and their need arise. They are aggregated at one central point from which the application is built. The default central point is the PublishedModules class. There is usually little to no benefit to changing this. A typical app composition would look like so:

namespace AllModules;

use Suphle\Modules\ModuleHandlerIdentifier;

use Suphle\Hydration\Container;

use AllModules\{ModuleOne\Meta\ModuleOneDescriptor, ModuleTwo\Meta\ModuleTwoDescriptor};

class PublishedModules extends ModuleHandlerIdentifier {
	
	protected function getModules():array {

		return [
			new ModuleOneDescriptor(new Container),

			new ModuleTwoDescriptor(new Container)
		];
	}
}

When the application is served (whether using traditional index.php or built through the RoadRunner bridge), only modules connected here will partake in the routing ritual.

Modules can depend on each other using the sendExpatriates method. Suppose ModuleTwoDescriptor has a dependency on ModuleOneDescriptor, the association can be defined as follows:

namespace AllModules;

use Suphle\Modules\ModuleHandlerIdentifier;

use Suphle\Hydration\Container;

use AllModules\{ModuleOne\Meta\ModuleOneDescriptor, ModuleTwo\Meta\ModuleTwoDescriptor};

use ModuleInteractions\ModuleOne;

class PublishedModules extends ModuleHandlerIdentifier {
	
	function getModules():array {

		$moduleOne = new ModuleOneDescriptor(new Container);

		return [
			$moduleOne,

			new ModuleTwoDescriptor(new Container)->sendExpatriates([

				ModuleOne::class => $moduleOne
			])
		];
	}
}

You may have observed introduction of the ModuleOne entity. It’s an interface that exists because modules aren’t driven by shells like ModuleOneDescriptor and ModuleTwoDescriptor. They are made of three crucial parts:

  • The descriptor or shell. This is the part connected to the aggregator or app entry point.
  • The module’s interface, typically consumed by any sibling module dependent on this module.
  • Module interface’s implementation. This can vary from application to application for the same module. The objective is for modules to be autonomous and isolated rather than tightly coupled to dependencies.

These parts are explored in greater detail on the Modules chapter of the documentation.

Routing

Suphle routes are defined as class methods instead of wrangling one gigantic script calling static methods on a global Router object. Perhaps, the biggest advantage of trie-based route handling is that it makes for fast failure i.e. easier to determine non-matching routes. Another benefit is that it allows us encapsulate the entrails or possible embellishments to those routes.

Basic routing

One notable novelty is that the eventual output type is defined within the method rather than in the controller/coordinator. Putting it all together, the average route collection could start out like this:

use Suphle\Routing\BaseCollection;

use Suphle\Response\Format\Json;

use AllModules\ModuleOne\Coordinators\BaseCoordinator;

class BrowserNoPrefix extends BaseCollection {

	public function _handlingClass ():string {

		return BaseCoordinator::class;
	}

	public function SEGMENT() {

		$this->_get(new Json("plainSegment"));
	}
}

With the definition above, requests to the “/segment” path will invoke BaseCoordinator::plainSegment and render it as JSON. Status code is 200 except an exception is thrown. The status code and other headers can either be set here or on the exception, as necessary. The _handlingClass method is a reserved method for binding one coordinator to all pattern methods on this class.

Nested collections

We can link to other collections using the _prefixFor method.

class ActualEntry extends BaseCollection {
		
	public function FIRST () {
		
		$this->_prefixFor(ThirdSegmentCollection::class);
	}
}

class ThirdSegmentCollection extends BaseCollection {

	public function _handlingClass ():string {

		return NestedCoordinator::class;
	}

	public function THIRD () {
		
		$this->_get(new Json("thirdSegmentHandler"));
	}
}

If we configure ActualEntry as this module’s entry route collection, the pattern for “first/third” will kick in. Removing the _prefixFor call will disconnect all collections from that point onward from the application. Sub-collections can control the eventual pattern evaluated using their _prefixCurrent method. When not defined, it simply uses the method name of the parent collection.

Sometimes, we may want to conditionally customize the prefix only if this collection is used as a sub in another. We can modify ThirdSegmentCollection like so:

class ThirdSegmentCollection extends BaseCollection {
		
	public function _prefixCurrent ():string {
		
		return empty($this->parentPrefix) ? "INNER": "";
	}

	public function _handlingClass ():string {

		return NestedCoordinator::class;
	}

	public function THIRD () {
		
		$this->_get(new Json("thirdSegmentHandler"));
	}
}

This will compose the same pattern as the previous one. However, when used as a sub-collection, the available pattern becomes “inner/third”. The parentPrefix property grants us access to the method name if any.

_prefixFor is a reserved method just like _handlingClass. There are other reserved methods for authorization, authentication, middleware application, etc. As you’ll expect, parent behavior propagates or fans out to any sub-collections nested beneath them.

Route placeholders

So far, we’ve only seen methods directly mapping to URL segments. Every now and then, dynamic paths have to be represented by placeholders. Since collection methods are legitimate methods, only permitted characters can be used. We differentiate between segment literals and placeholders using casing. For instance, the following definition would allow us intercept calls to “/segment/id”.

class BrowserNoPrefix extends BaseCollection {
		
	public function _prefixCurrent ():string {
		
		return "SEGMENT";
	}

	public function _handlingClass ():string {

		return BaseCoordinator::class;
	}

	public function id () {

		$this->_get(new Json("plainSegment"));
	}
}

For a simplistic scenario like the above, the method can be named SEGMENT_id. But we’ll be getting ahead of ourselves. There are other advanced sub-sections of method naming such as hyphenation, slashes and underscores. .

Route collections do other things ranging from dedicated browser/API-based CRUD routes, to canary routing, route versioning, route versioning, and route mirroring.

Failure-resistant execution

During development, some operational failures are easy to anticipate. These are usually covered either by manual or automated tests. However, there are mysterious, involuntary scenarios where application fails without project maintainer’s knowledge. There are many terrible fallouts of such incident, some of which are:

  • User sees a 500 page and is either turned off or is clueless about the next step.
  • Developer either has to sift through bulky logs or worse, is blissfully unaware of the crash until a user notifies him. At this point, the business must have lost a lot of money.
  • The upheaval may have been caused by one insignificant query in a payload where others succeeded.

A better experience for all parties involved would present itself as sandboxed wrappers that calls can be made in. Should any of them fail, instead of a full-blown crash, request will carry on as if nothing happened. But the developer will instantly be alerted about the emergency.

Suphle provides the base decorator, Suphle\Contracts\Services\Decorators\ServiceErrorCatcher, that when applied to service classes, will hijack any errors and perform the steps suggested above.

use Suphle\Contracts\Services\Decorators\ServiceErrorCatcher;

use Suphle\Services\{UpdatelessService, Structures\BaseErrorCatcherService};

class DatalessErrorThrower extends UpdatelessService implements ServiceErrorCatcher {

	use BaseErrorCatcherService;

	public function failureState (string $method) {

		if (in_array($method, [ "deliberateError", "deliberateException"]))

			return "Alternate value";
	}

	public function deliberateError ():string {

		trigger_error("error_msg");
	}

	public function deliberateException ():string {

		throw new Exception;
	}
}

The caller can read the status of immediate past operation success using the matchesErrorMethod method. In practice, that would look like this:

$response = compact("service1Result");

$service2Result = $this->throwableService->getValue();

if ($this->throwableService->matchesErrorMethod("getValue"))

	$service2Result = $this->otherSource->alternateValue(); // perform some valid action

$response["service2Result"] = $service2Result;

return $response;

But you’re unlikely to use this base decorator, because it’s extended by higher level ones that take care of concerns such as transactions, the kinds of row locking, change tracking, enforcing data integrity, among others. These and more are covered under the Service-coordinators chapter.

The future

Where to next from here? I use the following metrics to guage progress status of both mine and any greenfield project, and suspect you do, too:

  1. Roadmap completion.
  2. Project stability.
  3. Project longevity and support.

The short-term priority is concluding what chapters are left of the documentation and announcing an official release. This shouldn’t be confused for project instability, as all API and behavior is already cast in stone. There are items on the roadmap not checked off yet; most important to me is integrating a parallel test runner and implementing request scopes to circumvent container clean-up when application is ran in a long-running process. These are low-level additives that will definitely accompany the first release.

There are a few others I wish are ready, most notably, auto API documentation. Nevertheless, I won’t dare advocate this first version for production use if I wasn’t confident it’ll cover majority of enterprise requirements. The tests for the currently extensive feature set are all passing.

After this phase passes, Suphle will grow more audible to those who hang around dev spaces often — to both rally collaborative engagement and enlighten team leads/potential employers of a framework more suitable to work with. I’m not sure how long this will take, but I’m confident it’ll be worth the wait.

A detail worth mentioning is that Suphle has a Bridge component that allows you mount projects started in any other PHP framework, as long an adapter for it exists. So far, only a Laravel adapter has been written. But this ability to leverage years of development and support means that not only will Laravel-style routers work, so will service providers and everything else. These applications will be secluded to a configured folder, from where we expect relevant patterns to be left behind (standalone functions, facades, etc).

If you’re reading this, and Suphle looks interesting to you, there are various ways to get involved.

Bugs can be filed on Github. I’ve started discussions for those interested in disputing or improving some present features. I would like to assist with questions relating to direct usage on a Gitter channel but would refrain from setting one up since answers get lost in time, and are difficult to reference both manually and in search engines. If you plan to post questions on StackOverflow, and have >= 1500 rep, please create a “suphle” tag and let me know, so I can follow it as well as highlight it in publicity documents. You can also link to my profile on your StackOverflow questions, so I can get notified in order to respond:


Awesome question

// some code

https://stackoverflow.com/users/4678871

I sincerely hope Suphle is not only of immense benefit to you but that you enjoy using it. If you eventually do, consider leaving a star on the repo, telling your pals about it, and possibly watching for future updates.