sjparkinson/static-review
Static Review is a PHP tool that runs configurable static-analysis checks on files staged for commit, helping catch code-quality issues before they reach the repository. It integrates with linters like PHP_CodeSniffer and PHP Mess Detector and fits into Git hooks or custom workflows.
sjparkinson/static-review is a small framework for running checks against the files a Git hook is about to act on. You define a set of reviews in a PHP config file, and the package's CLI binary runs those reviews against the staged file list and exits non-zero if any review reports an error. Think of it as a pluggable dispatcher for pre-commit, commit-msg, and pre-push hooks rather than a linter itself.
Install it as a dev dependency:
composer require --dev sjparkinson/static-review
The first place to look is the project's .static-review.php file — it's the whole configuration surface. After that, read src/Review/AbstractReview.php (the extension point for custom checks), src/Review/ReviewCollection.php, src/Reporter/Reporter.php, and src/VersionControl/GitVersionControl.php.
A minimal config that blocks commits containing PHP syntax errors and refuses to run when the working tree has unstaged edits:
<?php
// .static-review.php
use StaticReview\Reporter\Reporter;
use StaticReview\Review\ReviewCollection;
use StaticReview\Review\Generic\UnstagedFilesReview;
use StaticReview\Review\PHP\PhpLintReview;
use StaticReview\VersionControl\GitVersionControl;
$reporter = new Reporter();
$git = new GitVersionControl();
$reviews = new ReviewCollection();
$reviews->add(new UnstagedFilesReview($reporter, $git));
$reviews->add(new PhpLintReview($reporter));
return $reviews;
Wire the binary into a hook (the binary takes the hook name as its argument):
cat > .git/hooks/pre-commit <<'EOF'
#!/bin/sh
exec ./vendor/bin/static-review pre-commit
EOF
chmod +x .git/hooks/pre-commit
First use case: catch a missing semicolon or unbalanced brace before the commit is written, instead of in CI twenty minutes later. Run ./vendor/bin/static-review pre-commit manually at any time to see exactly what the hook sees.
One config, per-hook branching. The hook name is passed as the CLI argument, so the same config can build different collections:
$hook = $_SERVER['argv'][1] ?? 'pre-commit';
$this->reviews->add(new PhpLintReview($reporter));
if ($hook === 'pre-push') {
$this->reviews->add(new PhpUnitReview($reporter)); // full test run only on push
}
Keep pre-commit fast (lint, whitespace, forbidden words) and push expensive checks (test suites, static analysis) to pre-push.
Custom reviews are the main integration point. Extend AbstractReview, implement canReview() as a cheap filter and review() as the actual work:
<?php
namespace App\Reviews;
use StaticReview\File\FileInterface;
use StaticReview\Review\AbstractReview;
class NoDebugStatementsReview extends AbstractReview
{
public function canReview(FileInterface $file)
{
return in_array($file->getExtension(), ['php', 'phtml'], true);
}
public function review(FileInterface $file)
{
$contents = file_get_contents($file->getPathname());
if (preg_match('/\b(var_dump|dd|dump)\s*\(/', $contents, $match)) {
$this->reporter->addReview($this, $file, sprintf('Found %s()', $match[1]));
}
return $this;
}
}
Register custom reviews with a PSR-4 autoload entry in composer.json, run composer dump-autoload, then add them to the collection.
Wrap external tools instead of reimplementing them. For phpcs, php-cs-fixer, eslint, or phpstan, write a thin review that shells out via Symfony\Component\Process\Process, passes $file->getPathname(), and reports non-zero exit codes through the reporter. This keeps the config declarative and lets you swap the underlying tool later.
Share a base class for tool wrappers. A ProcessReview abstract with protected function commandFor(FileInterface $file): array collapses each external tool into about ten lines.
Mirror the same reviews in CI. The hook is a convenience; run the identical command in CI (for example, ./vendor/bin/static-review pre-commit after checking out) so a developer with --no-verify doesn't silently bypass the checks.
symfony/process / symfony/console constraints. Pin PHP in your dev environment or vendor a fork rather than fighting it in a modern stack..git/hooks/ is per-clone and never committed. Provide an installer script (a Composer post-install-cmd or a bin/install-hooks script) and document it in the README, otherwise half the team never gets the checks.git add -p, the worktree differs from the index, so a linter reading the file on disk checks code that isn't being committed. Keep UnstagedFilesReview first in the collection or explicitly review the index content.canReview() runs for every file. Keep it to extension/name checks; no I/O, no process spawning. A slow canReview() on a 500-file commit is immediately noticeable.FileInterface object, not raw paths. The wrapper carries the repo-relative path and extension the rest of the framework relies on. file_get_contents($file->getPathname()) is fine; hard-coding getcwd() paths is not.PATH (GUI clients especially). Pass an explicit cwd to Process and use absolute binary paths if you see "command not found" only when committing from an IDE.-v in the 2017-era CLI, so add temporary fwrite(STDERR, ...) calls inside review() and remove them. Also confirm the hook script itself is executable — a silently no-op pre-commit is almost always chmod +x..bat/.cmd hook or run the binary through php. Line-ending reviews will also fight core.autocrlf.--no-verify is always available. Treat hooks as a fast feedback loop, not a security boundary; CI must enforce the same rules.AbstractReview subclasses, a custom Reporter implementation, and an alternate VersionControlInterface adapter (the package ships Git; others require writing one). Everything else is plain PHP composition in .static-review.php, so the config file itself is the cleanest place to inject project-specific paths and options.How can I help you explore Laravel packages today?