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:

  1. PHPUnit suite that locks in the Router + Request + Response contracts.
  2. README.md so the next person knows what they are looking at.
  3. LICENSE and .gitignore.
  4. Initial git commit + push to a GitHub repository.
  5. Annotated semver tag (v0.1.0).
  6. 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 can use your plugin classes without manual require calls. 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 of vendor/. 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 repositories entry. Submit the GitHub URL to packagist.org, hook up the webhook so new tags trigger Packagist re-scans, and consumers can composer require without 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

  1. You added PHPUnit as a dev dependency, wrote ten focused unit tests across RouterTest, RequestTest, and ResponseTest, and locked in the load-bearing contracts the rest of the plugin relies on.
  2. You wrote a README.md that pitches the plugin to a new reader, an MIT LICENSE, and a .gitignore that keeps vendor/ and PHPUnit's cache out of git.
  3. You initialised a git repository, pushed it to GitHub, and cut an annotated v0.1.0 tag so Composer's resolver can satisfy semver constraints.
  4. You documented the VCS-repository install pattern so consumers can composer require the plugin without you ever publishing to Packagist.
  5. A fresh Scriptor install on any machine can now pull the plugin from your GitHub repo via two composer.json edits and one composer require, with the plugin auto-registering through Scriptor's PluginManager on 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.