php-extended/placeholder-phpunit
A placeholder package for PHPUnit in php-extended projects. It provides minimal stubs so libraries can reference PHPUnit types without a hard dependency, keeping production installs lean while remaining compatible when PHPUnit is present.
php-extended/placeholder-phpunit is a stubbed mirror of PHPUnit's public API surface (PHPUnit\Framework\TestCase, Assert, Constraint, mock-object types, exceptions, attributes) shipped as a plain Composer package with zero transitive dependencies. It exists so a library can declare PHPUnit-facing APIs — type hints, extends, implements — without forcing phpunit/phpunit onto every consumer.
Minimal steps:
composer require --dev php-extended/placeholder-phpunit
vendor/php-extended/placeholder-phpunit/composer.json first — the version and require.php will tell you which PHPUnit API generation the stubs model. Then skim vendor/php-extended/placeholder-phpunit/src/ to confirm the symbols you need exist (Framework/TestCase.php, Framework/Constraint/, Framework/MockObject/, Framework/Attributes/).Constraint class, a TestCase trait, a listener, a data-provider helper. You want composer install to succeed and IDE/static-analysis to resolve your type hints even when PHPUnit is absent.The first thing to write is almost always a class that extends or implements a PHPUnit symbol:
namespace MyLib\Testing;
use PHPUnit\Framework\Constraint\Constraint;
final class IsValidTokenConstraint extends Constraint
{
public function toString(): string { return 'is a valid token'; }
protected function matches($other): bool { return (bool) preg_match('/^[a-f0-9]{32}$/', $other); }
}
With the placeholder on the autoload path this file parses and analyzes; without PHPUnit installed it is never executed.
Optional integration + suggest. The canonical pattern: ship the PHPUnit-facing API in your library, declare the placeholder as a hard requirement (or dev, depending on your package), and tell consumers what to install to use it. Guard at runtime instead of assuming PHPUnit exists:
{
"require": { "php-extended/placeholder-phpunit": "*" },
"suggest": {
"phpunit/phpunit": "Required to actually run the MyLib\\Testing constraints inside PHPUnit"
}
}
if (!class_exists(\PHPUnit\Framework\TestCase::class) || !method_exists(\PHPUnit\Framework\Assert::class, 'assertThat')) {
throw new \LogicException('Install phpunit/phpunit to use MyLib\\Testing.');
}
Static-analysis-only usage. For a tool that introspects PHPUnit test classes with php-parser, reflection, or Rector, the placeholder is a cheap way to give PHPStan/Psalm the symbol table without pulling in PHPUnit (and its PHP-version constraints) into a CLI tool's dependency graph. Add it to require-dev and point phpstan.neon at the vendor path if you want it treated as a stub rather than analyzed source.
Split CI matrices. Run one job with the placeholder installed (proves your library compiles standalone — no accidental PHPUnit coupling, no use of symbols your stub set doesn't cover) and a second job with real phpunit/phpunit (proves the integration actually works). The first job is the one this package uniquely enables; it will fail loudly if you reach for a PHPUnit class the stub set does not define, which is a useful "am I leaking PHPUnit into my core?" gate.
Extension points as the integration contract. Build your public surface around the interfaces PHPUnit itself exposes — Constraint, TestListener/TestHook, TestCase — rather than concrete assertion calls. Placeholder stubs cover the interfaces well; they are much thinner on the behavioral internals of TestCase, so interface-first design both survives the stubs and stays forward-compatible across PHPUnit majors.
class_exists/method_exists guard, and never let the placeholder be the only thing on the classpath in a test run.composer.json. Both declare PHPUnit\Framework\TestCase and friends, and Composer will happily install both — which of the two class files wins is determined by autoloader registration order, i.e. effectively arbitrary. Keep them in separate jobs, separate packages, or separate Composer roots.TestCase signatures, moved to attributes (#[DataProvider], #[Test]), dropped annotation support, and reshuffled the mock-object namespace. If your code compiles against the stubs but explodes under real PHPUnit, diff the stub's method signatures against your target PHPUnit version — that is the first place to look, not your code.PHPUnit\Framework\Attributes\ will still autoload fine for everything else; you only find out when the attribute class 404s under PHPUnit 10. Grep src/ for every FQCN you reference before you trust the install.replace is not a substitute. Some packages try "replace": {"phpunit/phpunit": "*"} to fake PHPUnit away. It pollutes the dependency graph and confuses Composer resolution; use a placeholder package like this one instead, scoped to require-dev where possible.composer dump-autoload -o, then grep -i phpunit vendor/composer/autoload_classmap.php to see exactly which package won the FQCN. If the placeholder shows up in a job that intended to run real PHPUnit, your require-dev is wrong.jetbrains/phpstorm-stubs already ships comprehensive PHPUnit stubs and is better maintained; reach for this package specifically when you need a Composer-installable autoload path (e.g. building docs, generating reflection data, or shipping a package whose classes extend PHPUnit types).composer require --dev phpunit/phpunit is the answer. This is a library-author's tool for keeping PHPUnit optional while still being type-safe.How can I help you explore Laravel packages today?