The Layer Nobody Should Have to Import
First published at Friday, 25 September 2026
The Layer Nobody Should Have to Import
My book describes an architecture in one sentence: Controller → Service → Gateway → Storage, strict dependency direction, one job per layer. Reading it again this summer I noticed it never says what goes in the HTTP box. It says what that box may do to the layers behind it, which is almost nothing, and then moves on.
So I published the thing I have been putting in that box for fifteen years: kore/framework. About four thousand lines of PHP across fifty-five files, no compile step, no configuration language, no annotations. It is on GitHub mostly so I stop copying it from one project into the next. I am not looking for users and I will not be taking feature requests.
Writing down why it is shaped the way it is turned out to be worth more than the code. The short version: a framework should be the dependency almost none of your code imports.
Rule zero
The development guidelines in the repository open with a rule that is the whole point of the package. Every proposed addition gets one question, and it is not "is this useful?":
Would an application's Service or Gateway have to import us to use it?
If yes, it does not belong in the framework. Write it in the application, or in a library of its own.
That question is also how the package came to exist. It was extracted from a real application by asking it about every class, one at a time, and the answers drew the boundary for me. It is why TokenVerifier, SharedTokenVerifier and ErrorReporter are interfaces here and implementations over there: verifying a bearer token is framework business, but issuing, refreshing and revoking tokens is the application's, and the moment the framework offers to do the second one your Services start importing it.
The consequence inside an application is the part people find strange. Controllers import the framework. The bootstrap imports the framework. Everything else should not know it exists. Your Services hold logic and no state, your Gateways hide storage, and neither has any reason to reference an HTTP library, because neither of them is doing HTTP.
Framework lock-in is treated as a law of nature in most of the ecosystem, and it is a choice. It is the accumulated result of a hundred small conveniences, each of which was genuinely convenient: a base class here, a facade there, an annotation that saves four lines of wiring, an attribute on a Data Object that only the serialiser understands. None of them are wrong on their own, and together they mean that changing framework means rewriting the application. So the measure I care about in a framework is not what it does. It is how few of my files have to mention it.
Symfony and Laravel are not the enemy of that idea, and I would rather show that than assert it. The demo application for the book now exists three times, the same domain implemented on Symfony, on Laravel and on this framework.
The numbers are the argument, and they are close enough to be boring. In the Symfony version the Vacation domain object has no imports at all, and its Service imports nothing but its own gateway interface and two shared domain types. Outside the controllers, the console commands and the security glue, every Doctrine import in the entire src/ tree is Doctrine\DBAL\Connection, appearing in exactly four gateway implementations, which is where a database connection is supposed to appear. In the Laravel version, thirty-two files live under app/Domain and two of them mention Illuminate: the gateway that talks to the database, and the one class Laravel's authentication contract requires you to write.
The version on my own framework is the same shape, which is the point rather than a surprise. Of its forty-nine source files, fifteen import the framework: the bootstrap, the four controllers, and the application's own HTTP plumbing, its session, flash messages and cross-site request forgery tokens, built on the framework rather than shipped inside it. Twenty-four files sit under a Domain/ directory and not one of them imports anything of mine. Of the nine gateways, exactly one does, and it is the database wrapper.
Holding that line costs something. There is a commit in the Symfony repository titled "Removed all ORM attributes again", which is what resisting a hundred small conveniences looks like in practice, and the word "again" is doing real work in that message. But it is available on a big framework, and a team hiring from a large pool or living off the ecosystem should take that trade. I reach for them less and less, because in my projects the part I would use is small and the part that would spread is not. The argument here is about coupling rather than size. A big framework used with discipline beats a small one that has quietly ended up in every file.
The Pareto point arrives earlier than frameworks assume
$request->json is a decoded object. $request->variables is an array of route matches and query parameters. There is no HeaderBag, no ParameterBag, no InputBag. Repeated HTTP headers are not modelled at all.
That last one is a real limitation and I chose it deliberately. Almost nobody needs repeated headers, and modelling them costs every reader of every controller a layer of indirection forever: you stop writing $request->headers['accept'] and start writing something that returns a collection you then have to ask for its first element, in every controller, for the entire life of the codebase, so that the handful of people who send multiple Accept headers are served. When one choice is simpler and covers the cases you actually have, the simpler choice wins. Where it turns out to be wrong for you, the abstract classes let you replace that piece instead of fighting it.
Everything is wired in plain PHP in the bootstrap: a container you register closures with, a router that maps a method and a path regexp to a callable. Nothing uses reflection to find your code, and there is no compiler pass. That is the same argument Toby and I made in 2013 about ContainerAware and it has not aged: when the framework locates your classes for you, the wiring moves out of code you can read and into behaviour you have to know about. I would rather type the wiring. It is fifteen lines, and it is the one place where an application's shape is visible at a glance.
Nothing defaults to somebody else's infrastructure either. Buckets, endpoints, origins, template directories and mail variables are required arguments, so a missing value is a startup error rather than a surprise in production at the worst possible moment.
The result is a framework you can read end to end in an afternoon, with a per-request overhead of a handful of object constructions, about 2ms. The idea is not new and I am not claiming it is: the direct predecessor is Qafoo/REST-Micro-Framework from our consulting years, and the split into Request, Router, Dispatcher and View is a descendant of the one we demonstrated there. Fifteen years of the same four concepts surviving every project I put them through is the only evidence I have for the design, and it is better evidence than a benchmark.
What it costs
There is no ecosystem. Nobody has written an admin generator for this, there is no Stack Overflow answer for your problem, and if it breaks at three in the morning the person who has to fix it is me. It is also young in a way the comparison above flatters: writing that third demo took the framework from 1.0.0 to 1.2.0 in a single day, because it had been extracted from an API-shaped application and the first server-rendered one walked straight into the gaps. Two of those releases took things back out of the public API, leaving the escaping helpers and the typing of form fields to the application, which is rule zero surviving contact with a real user. The rule is only worth anything if it still applies when applying it is inconvenient. For a team that is a serious argument against, and I would not put this into a company without saying so out loud. For my own projects the trade is obviously worth it, because I can read all of it.
The guidelines also list the places where the code does not follow its own rules, because guidelines that hide their exceptions are marketing. Database and Mailer take a configuration object and read it at runtime instead of receiving values, which is the older pattern and now waits for a major version. Middleware\Authentication reads $_SERVER directly rather than going through a property handler, which also means it quietly fails on several Apache and CGI setups. Mailer::substituteVariables() does not escape, so callers have to. An AdminAuthentication middleware denies everything and says "not yet implemented", which is honest as a deny-all and misleading as a name.
None of those are hard to fix. They are in a list because writing them down is cheaper than rediscovering them, and because a repository that only documents its good parts is not documentation.
The licence is EUPL-1.2, which is copyleft: use it in your application freely, but if you distribute a modified version or run one as a network service, the source goes back out. That is deliberate and it is the same position I have held since writing about the difference between product development and open source development. This is not a product, it has no roadmap, and I owe nobody a migration path.
Summary
I did not publish a framework because the world needs another one. I published the one box my own book leaves empty, so that there is something to point at when somebody asks what belongs in it. The argument I would defend even if you never touch the code is this: the quality of a framework is measured by how little of your application has to know about it, that number is a design choice rather than an inevitability, and the Pareto point for an HTTP layer arrives far earlier than the popular frameworks assume. Ask the one question about every class you are tempted to put in there — would a Service have to import this? — and most of the answers write themselves.
Subscribe to updates
There are multiple ways to stay updated with new posts on my blog: