You have a working plugin. This chapter does everything between "works on my laptop" and "installable on someone else's Scriptor". Six things, in order:
- PHPUnit suite that locks in the Router + Request + Response contracts.
README.mdso the next person knows what they are looking at.LICENSEand.gitignore.- Initial
git commit+ push to a GitHub repository. - Annotated semver tag (
v0.1.0). - The VCS-repository pattern that lets sites install your plugin without you ever publishing to Packagist.
None of this requires Scriptor to be running. The whole chapter plays out inside the plugin directory.
Add PHPUnit
PHPUnit is a dev-only dependency. Add it to composer.json:
cd ~/code/scriptor-simple-router
composer require --dev phpunit/phpunit:^11
That writes a require-dev block, installs PHPUnit into vendor/,
and updates composer.lock. Next, a minimal phpunit.xml.dist at
the plugin root tells PHPUnit where to look:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
Three keys carry weight:
bootstrap="vendor/autoload.php"so tests canuseyour plugin classes without manualrequirecalls. The PSR-4 autoload you set up in chapter 2 is what powers this.<directory>tests</directory>tells PHPUnit where to scan. Convention:tests/Unit/,tests/Integration/, etc., one directory per test category. This chapter only writes unit tests.cacheDirectory=".phpunit.cache"keeps PHPUnit's metadata out ofvendor/. Add the directory to.gitignore.
Write tests
Three small files, one per class. Each one locks in the contract the rest of the plugin relies on; future refactors that break them break loudly.
tests/Unit/RouterTest.php:
<?php
declare(strict_types=1);
namespace Bigins\ScriptorSimpleRouter\Tests\Unit;
use Bigins\ScriptorSimpleRouter\Request;
use Bigins\ScriptorSimpleRouter\Response;
use Bigins\ScriptorSimpleRouter\Router;
use PHPUnit\Framework\TestCase;
final class RouterTest extends TestCase
{
private Router $router;
protected function setUp(): void
{
// Router::instance() is a singleton; reset between tests so
// routes registered in one case don't leak into another.
// Reflection is the smallest hammer for that.
$ref = new \ReflectionClass(Router::class);
$prop = $ref->getProperty('instance');
$prop->setValue(null, null);
$this->router = Router::instance();
}
public function test_match_returns_route_and_params(): void
{
$this->router->get('/api/users/{id}', 'noop');
$hit = $this->router->match('/api/users/42', 'GET');
self::assertNotNull($hit);
self::assertSame(['id' => '42'], $hit['params']);
}
public function test_match_filters_by_method(): void
{
$this->router->get('/api/users/{id}', 'noop');
self::assertNull($this->router->match('/api/users/42', 'POST'));
}
public function test_dispatch_invokes_closure_handler_with_request(): void
{
$this->router->get('/api/users/{id}', static function (Request $req): Response {
return Response::json(['id' => (int) $req->param('id')]);
});
$response = $this->router->dispatch(new Request('GET', '/api/users/42'));
self::assertNotNull($response);
self::assertSame(200, $response->status);
self::assertSame('{"id":42}', $response->body);
}
public function test_dispatch_returns_null_on_no_match(): void
{
self::assertNull($this->router->dispatch(new Request('GET', '/nope')));
}
}
tests/Unit/RequestTest.php:
<?php
declare(strict_types=1);
namespace Bigins\ScriptorSimpleRouter\Tests\Unit;
use Bigins\ScriptorSimpleRouter\Request;
use PHPUnit\Framework\TestCase;
final class RequestTest extends TestCase
{
public function test_defaults(): void
{
$req = new Request();
self::assertSame('GET', $req->method);
self::assertSame('/', $req->path);
self::assertSame([], $req->query);
self::assertSame([], $req->body);
self::assertSame([], $req->pathParams);
}
public function test_with_path_params_returns_new_instance(): void
{
$original = new Request('GET', '/api/users/{id}');
$bound = $original->withPathParams(['id' => '42']);
self::assertNotSame($original, $bound);
self::assertSame([], $original->pathParams);
self::assertSame(['id' => '42'], $bound->pathParams);
}
public function test_accessors_fall_through_to_default(): void
{
$req = new Request(query: ['q' => 'php']);
self::assertSame('php', $req->get('q'));
self::assertNull($req->get('missing'));
self::assertSame('fallback', $req->get('missing', 'fallback'));
}
}
tests/Unit/ResponseTest.php:
<?php
declare(strict_types=1);
namespace Bigins\ScriptorSimpleRouter\Tests\Unit;
use Bigins\ScriptorSimpleRouter\Response;
use PHPUnit\Framework\TestCase;
final class ResponseTest extends TestCase
{
public function test_json_factory_sets_content_type_and_body(): void
{
$r = Response::json(['hello' => 'world']);
self::assertSame(200, $r->status);
self::assertSame('application/json', $r->headers['Content-Type']);
self::assertSame('{"hello":"world"}', $r->body);
}
public function test_redirect_factory(): void
{
$r = Response::redirect('/elsewhere', 301);
self::assertSame(301, $r->status);
self::assertSame('/elsewhere', $r->headers['Location']);
}
public function test_status_and_header_return_self_for_chaining(): void
{
$r = Response::text('OK')->status(201)->header('X-Total-Count', '42');
self::assertSame(201, $r->status);
self::assertSame('42', $r->headers['X-Total-Count']);
}
}
Run the suite:
vendor/bin/phpunit
Expected output (PHPUnit's exact format varies by version):
PHPUnit 11.5.x by Sebastian Bergmann and contributors.
Runtime: PHP 8.2.x
.......... 10 / 10 (100%)
Time: 00:00.040, Memory: 6.00 MB
OK (10 tests, 22 assertions)
Ten tests cover the load-bearing contracts: match returns the right shape, method filtering works, dispatch invokes the right handler with the right Request, no-match returns null, Response factories set the right Content-Type, and the fluent setters chain. That is enough to catch real regressions; chapter 6 is not where you chase 100% line coverage.
Write README.md
README.md is what GitHub shows on the repo page. Keep it short
and dense:
# scriptor-simple-router
A tiny URL router that runs ahead of [Scriptor](https://scriptor-cms.dev)'s
page resolver. Lets you declare JSON endpoints, webhook receivers,
and any other non-page-shaped URLs from a routes file, without
faking Pages.
## Installation
`composer require` from a VCS repository pointing at this GitHub
repo (no Packagist publication required):
```json
{
"repositories": [
{"type": "vcs", "url": "https://github.com/<you>/scriptor-simple-router"}
]
}
```
```bash
composer require bigins/scriptor-simple-router:^0.1
```
The plugin auto-registers via Composer's `installed.json`
(Scriptor reads `type: scriptor-plugin` packages at boot). One
extra line in your theme's `_ext.php` activates the router:
```php
if (\Bigins\ScriptorSimpleRouter\Router::handle()) return;
```
## Usage
Declare routes in a `routes.php` next to your theme's `_ext.php`:
```php
use Bigins\ScriptorSimpleRouter\Request;
use Bigins\ScriptorSimpleRouter\Response;
use Bigins\ScriptorSimpleRouter\Router;
$router = Router::instance();
$router->get('/api/users/{id}', fn(Request $req) => Response::json([
'id' => (int) $req->param('id'),
]));
$router->post('/webhook/stripe', StripeWebhookController::class);
```
Handler shapes: Closure, controller class string (uses
`__invoke`), or `[Class, method]` array.
## What's not in scope
- Middleware, route groups, per-route DI.
- Auto-magical integration with every theme: routing requires one
line in `_ext.php`.
Wrap [FastRoute](https://github.com/nikic/FastRoute) or
[league/route](https://route.thephpleague.com/) inside a Scriptor
plugin if you need richer features. The Scriptor Cookbook has the
recipe.
## Tutorial
This plugin is the artefact built across Build a Module in the
Scriptor Developer Guide. The tutorial walks through every line
of the source from scratch.
## License
MIT.
Add LICENSE and .gitignore
LICENSE: paste the standard
MIT license text with your
name and the year. Two-line decision: most Composer-installable
plugins are MIT or BSD-2; pick MIT unless you have a reason not
to.
.gitignore:
/vendor/
/.phpunit.cache/
.DS_Store
composer.lock is not in .gitignore for plugins. Composer's
guidance is: libraries (type: library, or plugin-like packages)
should commit composer.lock only as documentation of a known-good
combination; consuming sites resolve their own lock. Some teams
keep it out entirely. Either is defensible. The bundled
scriptor-markdown-pages plugin commits its lockfile; do whichever
matches your team's convention.
First commit
cd ~/code/scriptor-simple-router
git init -b main
git add .
git commit -m "Initial commit"
Create an empty repository on GitHub (do NOT initialise it with a README; you already have one). Then connect and push:
git remote add origin git@github.com:<you>/scriptor-simple-router.git
git push -u origin main
The repo now has your code, README, license, tests, and
.gitignore. What it does not yet have: a tagged version that
Composer's resolver can ask for by constraint.
Cut the v0.1.0 tag
Composer resolves version constraints (^0.1, ~1.2.0, etc.) by
reading git tags on the remote. No tag means consumers can only
ask for dev-main, which never gives a stable resolution.
Cut an annotated tag:
git tag -a v0.1.0 -m "Initial release: Router, Request, Response, handle() hook"
git push origin v0.1.0
Annotated tags carry an author, date, and message; lightweight
tags (git tag v0.1.0, no -a) are mutable references and break
Composer's "tagged releases are immutable" assumption when
overwritten. Always -a for releases.
Pick v0.1.0, not v1.0.0, on first publish. 0.x signals "API
not stable yet"; you can break things between 0.1 and 0.2
without a major bump. Switch to v1.0.0 when you commit to
semver-compatible changes on every subsequent release.
Install from VCS (no Packagist needed)
Consumers add a VCS repository entry to their Scriptor install's
composer.json and then composer require against the constraint:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/<you>/scriptor-simple-router"
}
]
}
composer require bigins/scriptor-simple-router:^0.1
Composer reads the VCS repo, finds the v0.1.0 tag, satisfies
^0.1, and installs the plugin into vendor/bigins/scriptor-simple-router.
Scriptor's PluginManager picks it up on the next request.
Publishing to Packagist is the same shape minus the
repositoriesentry. Submit the GitHub URL to packagist.org, hook up the webhook so new tags trigger Packagist re-scans, and consumers cancomposer requirewithout any extra config. Optional polish; the VCS-repository pattern works without it and is the right default while a plugin is still settling.
Verify the round-trip
The cleanest verification is a fresh Scriptor checkout on a different machine (or in a throwaway directory) that pulls the plugin from your remote:
# In a fresh Scriptor checkout:
cd ~/sandbox/another-scriptor
# Edit composer.json to add the VCS repository entry above
composer require bigins/scriptor-simple-router:^0.1
If composer require resolves to v0.1.0 and installs without
errors, the plugin is publishable. Add the one-line
Router::handle() to that install's theme _ext.php, drop in a
trivial routes.php, hit /api/hello, and you have proven the
end-to-end installation path from someone else's perspective.
What just happened, in five sentences
- You added PHPUnit as a dev dependency, wrote ten focused unit
tests across
RouterTest,RequestTest, andResponseTest, and locked in the load-bearing contracts the rest of the plugin relies on. - You wrote a
README.mdthat pitches the plugin to a new reader, anMIT LICENSE, and a.gitignorethat keepsvendor/and PHPUnit's cache out of git. - You initialised a git repository, pushed it to GitHub, and cut
an annotated
v0.1.0tag so Composer's resolver can satisfy semver constraints. - You documented the VCS-repository install pattern so consumers
can
composer requirethe plugin without you ever publishing to Packagist. - A fresh Scriptor install on any machine can now pull the plugin
from your GitHub repo via two
composer.jsonedits and onecomposer require, with the plugin auto-registering through Scriptor'sPluginManageron the next request.
The plugin is publishable. Anyone with the GitHub URL can install it, run it, and read the README well enough to wire it into their own site.
Where to go next
You finished Build a Module. Three useful follow-ups when you are ready:
- The Cookbook for recipes that build on this shape: wrapping a richer router library, adding auth middleware, generating OpenAPI specs from registered routes, sharing controllers across themes.
- The API Reference for class-by-class documentation
of every Scriptor surface this plugin touched (
PluginContext,PluginManager, the PSR-14 frontend events). - Build another plugin. The skeleton in chapter 2 takes about ten minutes once the shape is muscle memory. Plugin per concern, small and focused, is the Scriptor pattern.
The scriptor-markdown-pages plugin
(repo) is the
reference third-party plugin that exercises every API surface in
production. Read its source for the next layer of detail, or
copy-paste from its composer.json and src/Plugin.php for any
future plugins you write.
Behind the scenes. Composer's VCS resolver uses git's
ls-remote to enumerate tags and branches, so adding a tag and
pushing it makes it immediately installable; no Packagist polling
delay. The installed.json Scriptor reads at boot is rewritten on
every Composer operation, which is what makes "install the plugin
via composer, refresh the page, see the plugin load" work without
any extra cache-busting on the user's side. The discovery rules
themselves live in
boot/Plugin/PluginManager.php.