PHPUnit, Psalm, PHP-CS-Fixer, ESLint: One CI Pipeline for a Small App

Photo Sweep has no runtime dependencies and a small codebase, and still runs a full static analysis, coding standard, unit test and lint stack on every push. The two worst bugs it ever had needed a real server, not more lint — which is the point, not the counterargument.

PHPUnit, Psalm, PHP-CS-Fixer, ESLint: One CI Pipeline for a Small App

make test runs three things: composer run cs:check, composer run psalm, composer run test:unit. Add npm run lint for the Vue frontend and that's the whole quality gate for an app with no runtime Composer dependencies at all — Nextcloud autoloads it directly, there's no vendor/ to ship. It would be easy to argue a project this size doesn't need four separate tools checking it. It runs them anyway, and the CI matrix runs some of them twice.

php-cs-fixer, psalm, phpunit, and eslint, all in one make target
php-cs-fixer, psalm, phpunit, and eslint, all in one make target

A small app still runs all of it — the two bugs that hurt most needed a real server, not more lint.

What each layer actually catches

PHP-CS-Fixer (composer run cs:check, run with --dry-run --diff) enforces the Nextcloud coding standard. It catches nothing a human couldn't also catch in review — its value is catching it before review, so a pull request doesn't burn a maintainer's attention on spacing.

Psalm, run with --no-cache against the nextcloud/ocp stubs, is the one doing real work. It type-checks the app without needing a live Nextcloud instance at all — the stubs describe OCP's shape well enough that psalm can catch a wrong argument type, a nullable return value used without a check, or a method that doesn't exist on an interface, purely from reading the source. It's also the tool with the most friction: two @psalm-suppress annotations show up in MediaFinder.php and TrashService.php, exactly where the code deliberately steps outside OCP into unstable internals psalm's stubs don't describe. The suppressions are a marker, not a workaround — they sit precisely at the two places already flagged as the app's private-API blast radius.

PHPUnit runs plain unit tests, and the README is specific about what they're aimed at: "the two places where a quiet mistake would be worst: resolving capture dates, and bucketing timestamps into months across a timezone boundary." Not comprehensive coverage of everything — targeted coverage of the two pieces of logic where a subtle bug produces a wrong answer that looks plausible rather than an obvious crash. That's a deliberate scoping choice, not a gap.

ESLint covers the Vue frontend, run through the same npm run lint that also gates the build in CI — a build that produces a broken frontend bundle fails the same job that would have caught the code-style problem that caused it.

The matrix runs this on two PHP versions and two Nextcloud versions

strategy:
  matrix:
    php: ['8.1', '8.4']

8.1 is the floor info.xml declares; 8.4 is roughly what a current Nextcloud install actually runs. A separate integration job repeats this logic against the server itself:

strategy:
  matrix:
    nextcloud: ['31', '34']

"Both ends of the range info.xml declares support for. Anything in between that breaks will almost always break one of these too" — a deliberately cheap approximation of full-matrix coverage that catches almost everything a full matrix would, at a quarter of the CI minutes.

Why static checks weren't enough, and the app knows it

The integration job's own comment is the most candid line in the whole CI config: "Unit tests and psalm passed on both of the worst bugs this app has had: a keep verdict that could never be saved, and a restored photo that never returned to the index. Only a real server catches that class of thing." That's not an argument against psalm and phpunit — it's the reason the integration job exists in addition to them. Static analysis and unit tests catch a huge, cheap, fast-feedback class of mistake. They provably did not catch the two bugs that mattered most, because those bugs lived in the interaction between this app's code and Nextcloud's actual runtime behavior — exactly the thing no stub or mock can stand in for. Running all four tools, plus a real Nextcloud in Docker, isn't over-engineering for a small app. It's an admission, backed by this project's own bug history, that no single layer of testing is the whole story.

Series: Photo Sweep.