dillonsmart / tetherphp
TetherPHP is a PHP framework which ships as a skeleton application.
Requires
- php: ^8.5
- dillonsmart/tetherphp-core: ^0.14
Requires (Dev)
- phpunit/phpunit: ^11.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Built with the help of Claude Code, in public.
Why
Most of the time spent on an application is spent reading it, and more and more often the reader is not the person who wrote it. It is a colleague picking up the code, or an AI agent working on it.
Frameworks tend to make that harder than it needs to be. Things get resolved by conventions nobody wrote down, dependencies get pulled from a container, and a request passes through layers of indirection before it reaches your code. If you know the framework's folklore, none of that is a problem. If you don't, every step is a "how did that happen?"
TetherPHP exists so that a request can be followed from route to response just by reading the code. If something matters, it is visible. A file's location follows from its name. A class is handed what it depends on. And the console can show you what the application does, so nobody has to guess. What makes the code obvious to a person makes it obvious to an agent too.
What it is
A small PHP framework built on the Action-Domain-Responder pattern. Each route has one Action, which calls a Domain that knows nothing about HTTP, and hands the result to a Responder that decides what the view gets.
It is opinionated about structure and deliberately narrow in scope. There is no ORM, no query builder, no validation layer and no container — those can be added as packages. What ships is routing, requests, responses, middleware and the console.
It is built in public, with Claude Code. An AI agent writes much of the code; the reasoning behind each change is in the commit messages, and the dev log records what broke and why. Development is posted on X at @DillonDevStuff.
What it looks like
This is the home page of a fresh install, end to end. A route names an Action:
// routes/web.php $router->get('/', Actions\Home\Index::class);
The Action is constructed with the request and the application's services, builds its Domain and Responder, and connects them. It coordinates; it holds no logic and builds no markup:
// app/Actions/Home/Index.php class Index extends Action implements ActionInterface { public function __construct(protected Request $request, Services $services) { $this->domain = new IndexDomain($services->env); $this->responder = new IndexResponder($request); } public function __invoke(): Response { return $this->respond($this->domain->handle()); } }
The Domain does the work. It knows nothing about HTTP, takes what it needs through its constructor, and returns a value object rather than an array:
// app/Domains/Home/Index.php class Index extends Domain { public function __construct(private readonly Env $env) { } public function handle(): Page { return new Page( name: $this->env->get('APP_NAME', 'TetherPHP'), description: 'An application built with TetherPHP.', ); } }
The Responder is the only place the view's variables are named. Rename $tagline in the template and the Domain
does not change:
// app/Responders/Home/Index.php class Index extends Responder { public function __invoke(Page $result): Response { return $this->view('pages.home.index', [ 'appName' => $result->name, 'tagline' => $result->description, ]); } }
Nothing above is resolved by convention, discovered by scanning, or pulled from a container. Every arrow in
Request → Route → Action → Domain → Responder → Response is a line you can point at.
Features
- ADR architecture — one Action per route, and a request path you can trace by reading it
- Routing —
get,post,put,patchanddelete, dynamic segments and route groups. Captured parameters reach your Action exactly as they were sent, so slugs and UUIDs survive - Requests — path parameters, the query string and the request body are each available where you would expect:
$request->params,$request->queryand$request->payload. The body is parsed for every verb, not just POST - CRUD generation —
make:resourcewrites a whole resource: seven Actions, Domains and Responders, the Results they share, and the views - Middleware — CSRF protection and
_methodoverriding ship with the framework, but you opt into them inroutes/middleware.php. An API can leave them out and boot with no session at all - Introspection —
routes,explain,inspectandcontextreport what the application actually does. The last one prints JSON, for tooling and agents - Environment — a
.envfile, handed to the Kernel bypublic/index.phprather than found by the framework - Dependencies without a container —
app/Services.phplists what the application is made of,public/index.phpbuilds it, and every Action is handed it to pass its Domain what the Domain needs - Tailwind CSS — pre-configured
- Logging — to
storage/logs/
Requirements
- PHP 8.5 or higher — property hooks and
newwithout parentheses are used throughout, so 8.4 fatals rather than degrades - Composer
Getting started
Create a project with Composer, then copy the example environment file:
composer create-project dillonsmart/tetherphp ./ cp .env.example .env
Run it on PHP's built-in server, or in Docker:
php tether serve # http://127.0.0.1:8000 docker compose up --build # http://localhost:8000
php tether test runs the test suite, and php tether help lists every command.
Building assets
Styling is Tailwind CSS. Install the dependencies and build the stylesheet:
npm install && npx tailwindcss -i ./resources/css/app.css -o ./public/css/app.css --watch
Project structure
├── app/
│ ├── Actions/Home/Index.php # one class per route
│ ├── Commands/ # your console commands (make:command)
│ ├── Domains/Home/Index.php # business logic, no HTTP
│ ├── Domains/Home/Results/Page.php # the value object the Domain returns
│ ├── Responders/Home/Index.php # names the view's variables
│ ├── Services.php # what the application is made of; built in public/index.php
│ └── Views/pages/home/index.php
├── public/ # web root (index.php, compiled assets)
├── resources/ # source assets (CSS)
├── routes/web.php # where a request goes
├── routes/middleware.php # what it passes through
├── storage/ # logs and application storage
├── tests/ # Unit (a Domain alone) and Feature (through the Kernel)
└── tether # the console
Every feature is a directory. Actions\Home\Index lives at app/Actions/Home/Index.php, with its Domain,
Result and Responder in the matching places. A second route is a second class beside the first — php tether make:action Home Show — never a second method on the same one.
The console generates that structure for you, and can report on it:
php tether make:feature Blog # one page: Action, Domain, Result, Responder and view php tether make:resource Post --uri=/posts # a full CRUD resource php tether routes # what is registered, and what wraps it php tether explain /posts/12 # resolve one URL the way a request would
The framework itself is not part of this repository. It is installed as the
dillonsmart/tetherphp-core Composer package and lives in
vendor/dillonsmart/tetherphp-core.
Naming things
TetherPHP is opinionated about names, because a name is how you find a file and how the console finds it. There is
one way, the generators write it, and the rules fit in a table. Note is the feature and Store is the operation
throughout:
| Thing | Rule | Example |
|---|---|---|
| Feature | a singular noun in PascalCase; one directory of that name under Actions/, Domains/ and Responders/ |
Note |
| Operation | a verb in PascalCase; one class per operation, with the same name in all three layers | Actions\Note\Store, Domains\Note\Store, Responders\Note\Store |
| CRUD operations | the seven verbs make:resource writes, and no others |
Index Create Store Show Edit Update Destroy |
| Result | named for its shape, never for the operation; final readonly, under Results/, shared by every operation that answers the same way |
Domains\Note\Results\Record — used by Show and Edit alike |
| The result shapes | Collection many, Record one, Written a change, Invalid a refusal, Page none of those |
handle(): Written|Invalid |
| Collaborator | a noun at the root of the feature's Domain namespace: shared by the operations, routed to by nothing | Domains\Note\Notes (the queries), Domains\Note\Attributes (the rules) |
| View | app/Views/pages/<feature>/<operation>.php, both lowercase; referred to in dot notation |
pages/note/create.php, $this->view('pages.note.create', …) |
| Partial | app/Views/partials/<name>.php, kebab-case |
partials/note-form.php |
| Error view | app/Views/errors/<status>.php |
errors/404.php |
| Route | kebab-case URI, plural for a resource, {param} for a segment; make:resource --uri sets it |
/notes, /notes/{id}, /notes/{id}/edit |
| Command | app/Commands/<Name>Command.php; invoked in kebab-case, or namespaced with a colon |
DbSchemaCommand → php tether db:schema |
| Services | one class, App\Services at app/Services.php; a class of your own that it holds goes under app/Services/ |
App\Services\Mailer |
| Test | tests/Unit/ for a Domain or Result on its own, tests/Feature/ for a request through the Kernel; <Subject>Test.php |
tests/Unit/AttributesTest.php, tests/Feature/NotesTest.php |
| Env key | UPPER_SNAKE_CASE in .env, read with $env->get('DB_DSN') |
APP_NAME, DB_DSN |
Three of those carry the rest:
- Same name, three layers. A route names an Action; the Action's name is the Domain's name is the Responder's
name. That one-to-one path is what lets
php tether inspect Note\Storeshow you all three, and why a Domain's operations never move into a subdirectory. - Verb or noun. In
Domains/Note/, a verb is an operation and a noun is a collaborator. You can tell which is which from the directory listing. - A Result is a shape. Seven operations do not need seven result classes; they need four. If you find yourself
writing
Results\Show, stop.
The generators enforce all of it — php tether make:action Note Archive puts the file where the table says — and
php tether context reports the conventions as JSON for anything that reads them by machine.
Documentation
The documentation covers routing, requests, responders, middleware, services, CRUD and
the console. php tether help <command> explains any one command.
A complete example
This skeleton is a starting point, so its one feature does nothing. To see what a feature looks like with a database
behind it, a form in front of it and input to refuse, read
tetherphp-demo: a notes application built from this skeleton
with tether make:resource, using SQLite through Services, with validation, a 422 that carries the form back, and
feature tests through the real Kernel against an in-memory database. Its README says what to read in what order.
Working on the framework itself
Framework changes belong in the tetherphp-core repository, not here. To develop both together, clone them as siblings and point this application at your local core checkout:
~/your-projects/
├── tetherphp/ # this repository
└── tetherphp-core/ # the framework package
cp composer.local.json.example composer.local.json COMPOSER=composer.local.json composer update
That installs the core package as a symlink to ../tetherphp-core, so edits there take effect immediately with no
reinstall. composer.local.json and composer.local.lock are gitignored, so the linked setup never leaks into a
commit; composer.json continues to describe the real published dependency.
An alias helps:
alias composer-local='COMPOSER=composer.local.json composer'
To go back to the published package, remove the overlay and reinstall:
rm -rf vendor composer.local.json composer.local.lock && composer install