The skeleton
A project built on the Quillstack Framework, a light and simple micro-framework to build APIs.
Requirements
- PHP 8.1 or newer
- the
jsonextension - Composer
Getting started
composer create-project quillstack/quillstack my-api
cd my-api
composer servecomposer create-project copies .env.example to .env for you. When you clone this repository by hand, do it yourself:
composer install
cp .env.example .env
composer serveThe application is then served at http://localhost:8000:
$ curl http://localhost:8000/
{"app":"The Quillstack Framework","version":"1.0.0"}/users/:id is behind authentication, so asking without a token is refused rather than answered — see Authentication for where the token comes from:
$ curl http://localhost:8000/users/1
{"error":{"status":401,"message":"Unauthorized"}}
$ curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/users/1
{"id":1,"email":"ada@example.com"}Routes
Routes live in src/Providers/RouteProvider.php:
public function setRoutes(Router $router): void
{
$router->get('/', HomeController::class)->name('home');
$router->get('/users/:id', UserController::class)->name('users.show');
}get(), post(), put(), patch(), delete(), options() and head() register a single method, match(['PUT', 'PATCH'], ...) registers a few of them, and any() registers them all.
A path segment written as :id or as {id} is a parameter. Matched parameters are put on the request as attributes, so a controller reads them from the request it is handed:
public function handle(ServerRequestInterface $request): UserResponse
{
$user = $this->orm->repository(User::class)->find(
(int) $request->getAttribute('id')
);
return $this->response->with($user);
}What goes over the wire
A response says which object it carries, and the entity says which of its fields may go — beside the field itself:
#[Table('users')]
final class User
{
public function __construct(
#[Id, Exposed] public ?int $id = null,
#[Column(unique: true), Exposed] public string $email = '',
#[HasMany(Post::class, 'user_id')] public readonly Related $posts = new Related(),
) {
}
}{"id": 1, "email": "ada@example.com"}The posts are not there: nobody said they may go, so they do not, and loading them is not started to find that out. src/Responses/UserResponse.php is empty apart from the class line, and there is nothing to keep in step — a column added tomorrow is not in the API today, and a field renamed does not quietly stop being sent. A response written as a list of fields is a place a field can be forgotten in both directions.
A response serving a particular audience says so, and gets the fields marked for it:
final class AdminUserResponse extends SerializedResponse
{
protected function groups(): array
{
return ['admin'];
}
}Authentication
A route says what reaching it requires, and one place enforces it — the controller has nothing to remember:
$router->get('/users/:id', UserController::class)->name('users.show')->requireAuthentication();src/Auth/Users.php says who a token belongs to, which is the one class an application writes to have authentication at all. What is stored is the hash of the token, so a database somebody reads holds nothing they could sign in with:
public function findByToken(string $token): ?Identity
{
$found = $tokens->one($tokens->query()->where('hash', '=', Token::hash($token)));
return $found === null ? null : new Identity($found->userId, $found->roles());
}$ curl -i http://localhost:8000/users/42
HTTP/1.1 401 Unauthorized
{"error": {"status": 401, "message": "Not authenticated"}}
$ curl -i -H "Authorization: Bearer $TOKEN" http://localhost:8000/users/42
HTTP/1.1 200 OK
{"id": "42"}Making a token, and keeping only what should be kept:
$token = Token::create(); // hand this over, once
$tokens->save(new ApiToken(userId: 1, hash: Token::hash($token)));A guarded route in an application which has said nothing about identities is refused at boot, before a single request is served — such a route would be open while reading as guarded.
Queues
Work which does not have to happen while somebody is waiting goes on a queue. A message says what is to be done, a handler does it:
$queue->push(new SendWelcomeEmail($email));src/Providers/QueueProvider.php says where messages wait and what handles each of them. The example writes them under var/queue and handles them by appending to var/welcome.log.
./bin/quill queue:work # everything due now, then stop
./bin/quill queue:work emails # a queue of its own
./bin/quill queue:work --keep-running # wait for moreA message which fails is tried again a few times, waiting longer each time, and then set aside rather than kept in the way of everything behind it.
Database
Entities describe the tables, so there are no migration files to write and none to keep in order. src/Entities holds them and src/Providers/EntityRegistry.php names them:
#[Table('posts')]
final class Post
{
/** @param Reference<User> $user */
public function __construct(
#[Id] public ?int $id = null,
#[Column('user_id')] public ?int $userId = null,
#[Column] public string $title = '',
#[BelongsTo(User::class, 'user_id')] public readonly Reference $user = new Reference(),
) {
}
}Declaring that relation is what puts an index and a foreign key on posts.user_id. Nobody writes either:
$ ./bin/quill db:migrate --pretend
CREATE TABLE "posts" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NULL,
"title" TEXT NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
)
CREATE INDEX "posts_user_id_index" ON "posts" ("user_id")Drop --pretend to run it. Nothing is ever removed: a column the entities no longer mention is reported and left alone.
Left unconfigured the database is a SQLite file under var/; DB_DSN in .env points it anywhere else. Reading is where quillstack/orm earns its keep — touching one entity's relation loads it for every entity read beside it, so walking users, their posts and the comments on those is three queries rather than one per row.
Layout
public/index.php the entry point
src/Controllers controllers, one action each
src/Responses response classes; one carrying an object needs nothing in it
src/Providers routes, commands and services the application brings
src/Entities the tables, and the relations between them
src/Messages what goes on a queue
src/Handlers what handles it
src/Services your own services
tests/unit.php the list of test classes to runControllers, services and responses are resolved by the container. Ask for what you need through the constructor:
final class HomeController implements ControllerInterface
{
public function __construct(
private readonly HomeResponse $response,
private readonly VersionService $versionService
) {
}
}Tests
composer testStatic analysis runs at PHPStan's strictest level:
composer stanCoverage needs phpdbg, which is a separate binary shipped with PHP:
composer test:coverageLicense
MIT. See LICENSE.