Contributing

August 1, 2026 ยท View on GitHub

Pull requests are welcome - bug fixes, drivers, documentation, or a failing test that demonstrates something is wrong. Small and focused beats large and sweeping; if a change is big enough to need a discussion, open an issue first so the design is agreed before anyone writes it.

Branches and versions

Branch / refWhat it is
masterThe latest released code. What is on Packagist as a stable version.
developActive development. Base pull requests here unless it is a hotfix.
tagsReleases, vMAJOR.MINOR.PATCH. A tag is what Packagist publishes.

The major and minor come from .version; the patch is derived from git. See Where the version number comes from.

Versioning is semver: a breaking change to anything under src/ is a major, new behaviour that keeps existing calls working is a minor, everything else is a patch. master is only ever fast-forwarded from develop at release time, so a commit on master is always a commit that shipped.

A hotfix against a release branches from master, and is merged back into both master and develop.

Where the version number comes from

Two places, and only two:

  • .version holds major.minor and is the only part a human edits.
  • The patch number is the commit count, git rev-list --count HEAD.

So the release tag for a commit is v$(cat .version).$(git rev-list --count HEAD), which scripts/version.bash prints:

./scripts/version.bash        # 2.0.324

Deriving the patch means it never needs a commit of its own and cannot conflict on a merge. The count keeps rising across a minor bump, so 2.1 picks up where 2.0 left off and every version still sorts after the last - patch numbers are not contiguous, and are not meant to be read as "the 324th patch".

Bump .version by hand when the change deserves a new minor or major, in the commit that makes it true.

Releasing

  1. .version is right for what is about to ship.

  2. CHANGELOG.md: rename the unreleased heading to major.minor and today's date, e.g. ## 2.1 - 2026-09-14. The patch number belongs on the tag, not in the changelog.

  3. ./scripts/code_tests.bash is green, and CI is green on master.

  4. Tag it:

    ./scripts/release_tag.bash
    

    It computes the tag, refuses a dirty tree, refuses a tag that already exists, and refuses one that sorts below an existing tag - which is what a rebase or a squash would produce, since a shortened history means a smaller commit count. It creates the tag locally and stops.

  5. Push it:

    git push origin v2.0.324
    

Packagist publishes on the tag push, so that push is the point of no return. Nothing before it is public.

Working on it

Everything runs in docker, so no php, composer or database has to exist on the host.

docker compose build develop
docker compose run --rm develop bash

That mounts the working tree, so edits are visible in the container immediately. Inside:

composer install
./scripts/code_tests.bash

code_tests.bash is what CI runs, and it is the only thing that has to pass: php -l over every file, phpcs against phpcs.xml, the phpunit suite, a --strict-psr autoloader dump, and composer validate. Individual pieces, if you want them separately:

composer test                        # phpunit
composer lint                        # phpcs
vendor/bin/phpunit --filter RouterTest
vendor/bin/phpcbf --standard=phpcs.xml src   # fix what phpcs can fix on its own

The testing compose service bakes the source into the image instead of mounting it, which is how CI sees the tree. Rebuild it after edits.

scripts/git_pre_commit.bash runs the same checks as a hook, if you would rather find out before pushing.

Tests

The suite is self-contained: it points APP_PATH at this package, so the framework stands in for an application and no demo app has to exist. The migration and i18n suites run against a real sqlite database in a temp file rather than a mock, so nothing there needs a service container either.

New code needs a test. A bug fix needs a test that fails before the fix - if it passes against the unfixed code, it is not testing the bug.

Two things the suite deliberately covers that are easy to break without noticing:

  • The package must work without twig. composer install --no-dev followed by php scripts/boot_without_twig.php is a separate CI job, because the main suite has twig installed as a dev dependency and would not catch a hard reference to it.
  • Optional dependencies. Anything under suggest in composer.json belongs to one class. If you add such a class, add the entry, and add it to require-dev with a test, or the file is one nothing ever loads.

Style

phpcs.xml is the authority and CI enforces it. Beyond what it can check:

  • Comments explain why, not what. Match the density of the file you are in.
  • Always use braces, even for a single statement.
  • No emoji in code, comments or commit messages.

Databases

The migration drivers cover postgres, mysql/mariadb and sqlite. CI exercises sqlite, because it needs no service container. If you touch a driver, test it against the real engine - a throwaway container is enough:

docker run --rm -e POSTGRES_PASSWORD=secret -p 55432:5432 postgres:17-alpine
docker run --rm -e MYSQL_ROOT_PASSWORD=secret -p 33306:3306 mysql:8.4

Point an application's config['db']['pdo'] at it and drive staticphp migrate end to end: status, apply --dry-run, apply, a deliberately failing migration, then forget / baseline / repair. The engines differ in ways the drivers exist to paper over - mysql has no transactional DDL, so a failed migration there is recorded as FAILED and may have partially applied, while postgres rolls the whole thing back.

Pull requests

  • Base on develop, one topic per PR.
  • ./scripts/code_tests.bash green.
  • Say what breaks. If a change is breaking, it waits for the next major, so it needs to be worth it - and it needs an UPGRADE.md entry.
  • Update CHANGELOG.md under the unreleased heading.