peridot-php/peridot
Peridot is a lightweight, extensible PHP testing framework with a descriptive BDD-style syntax. Write expressive specs using describe/it blocks, custom DSLs, and plugins, with clear reporting for readable, maintainable test suites.
Peridot is an event-driven BDD framework. You write describe/it specs, and the runner emits lifecycle events that reporters and plugins subscribe to. The core is small; most day-to-day leverage comes from the runner, the DSL, and custom reporters/plugins.
Install and first spec
composer require --dev peridot-php/peridot peridot-php/expect
mkdir -p specs
specs/Calculator.spec.php:
<?php
use Calculator;
describe('Calculator', function () {
beforeEach(function () {
$this->calc = new Calculator();
});
it('adds two numbers', function () {
expect($this->calc->add(2, 3))->to->equal(5);
});
it('rejects non-numeric input', function () {
expect(function () {
$this->calc->add('a', 1);
})->to->throw('InvalidArgumentException');
});
});
Run:
vendor/bin/peridot
vendor/bin/peridot specs/Calculator.spec.php
vendor/bin/peridot --reporter=dot
Where to look first
vendor/bin/peridot --help — CLI flags (--reporter, --grep, focus/skip behavior, config path).peridot.php at project root — the config file. It must return a closure that receives the event emitter.specs/ — default spec discovery directory; files matching *.spec.php are loaded recursively.Peridot\Reporter\ReporterInterface and Peridot\Reporter\AbstractReporter in vendor/peridot-php/peridot/src — the extension surface most teams end up using.peridot-php/expect — the assertion library; core Peridot intentionally ships little in the way of assertions.First real use case
Port one small service or value object into a single spec file. This validates three things at once: Composer autoloading for your app classes, the assertion style your team will use, and CI exit-code behavior. Do this before committing to migrating a larger suite.
Minimal peridot.php:
<?php
return function ($emitter) {
// Register listeners, reporters, plugins here.
$emitter->on('runner.start', function () {
// e.g. bootstrap app environment, set timezone, etc.
});
};
Spec organization
Subject.spec.php.describe()/context() blocks for state variations, not for grouping unrelated assertions.before() runs once per suite; beforeEach() runs per test. Put expensive bootstrap (container, DB connection, HTTP client) in before, and isolation/rollback in beforeEach.$this->foo is the idiomatic way to share fixture state between hooks and tests. Do not expect constructor injection inside specs.describe('UserRepository', function () {
before(function () {
$this->pdo = new PDO('sqlite::memory:');
$this->repo = new UserRepository($this->pdo);
});
beforeEach(function () {
$this->pdo->exec('BEGIN');
$this->repo->create(['email' => '[email protected]']);
});
afterEach(function () {
$this->pdo->exec('ROLLBACK');
});
it('finds a user by email', function () {
expect($this->repo->findByEmail('[email protected]'))->to->not->be->null;
});
});
Red/green iteration
vendor/bin/peridot specs/UserRepository.spec.php.vendor/bin/peridot --grep='finds a user'.This is the highest-value daily loop. Keep the file argument in muscle memory; Peridot’s startup is fast enough that per-file runs are cheap.
CI integration
vendor/bin/peridot --reporter=tap --no-colors.composer.json so local and CI commands match:{
"scripts": {
"test": "peridot --reporter=dot",
"test:ci": "peridot --reporter=tap --no-colors"
}
}
Data-driven specs
There is no @dataProvider equivalent in core. Use PHP loops to generate specs, or loop inside a single it() and assert per case. The loop-generating approach gives better failure granularity:
$cases = [
[2, 3, 5],
[-1, 1, 0],
[0, 0, 0],
];
foreach ($cases as [$a, $b, $expected]) {
it("adds {$a} + {$b}", function () use ($a, $b, $expected) {
expect((new Calculator())->add($a, $b))->to->equal($expected);
});
}
Note the use clause: outer variables are not automatically visible inside spec closures.
Custom reporters
Reporters are the cleanest integration point for dashboards, JUnit-style output, or team-specific summary formats.
use Peridot\Reporter\ReporterInterface;
class SlackSummaryReporter implements ReporterInterface
{
protected $emitter;
protected $output;
protected $failures = 0;
public function __construct($emitter, $output)
{
$this->emitter = $emitter;
$this->output = $output;
}
public function init()
{
$this->emitter->on('test.failed', function ($test, $exception) {
$this->failures++;
$this->output->writeln("FAIL: " . $test->getDescription());
});
$this->emitter->on('runner.end', function () {
$this->output->writeln("Failures: {$this->failures}");
});
}
}
Register it from peridot.php against the emitter, and keep the reporter’s output going through the provided output object so --no-colors and redirection behave correctly.
Event-driven extensions
The emitter is the public extension seam. Typical lifecycle events include:
runner.start / runner.endsuite.start / suite.endtest.start / test.endtest.passed / test.failed / test.skipped (and pending/skipped variants depending on version)Use runner.start for one-time setup and test.failed/test.passed for reporting. For cross-cutting concerns — environment setup, timing, retries, notifications — prefer listener functions in peridot.php over forking the framework.
Plugins
Peridot has a plugin model for reusable extensions (concurrency, watching, mocking, coverage historically existed in the ecosystem). Treat plugins as version-sensitive: check that the plugin’s composer.json supports your Peridot and PHP versions before adopting it. For most teams, a small peridot.php listener plus a custom reporter covers the same ground with less dependency risk.
Mocking and test doubles
Peridot is assertion-agnostic about doubles. Options:
peridot-php/expect for assertions.beforeEach() and torn down in afterEach().Keep doubles in $this-> properties so all tests in the suite share consistent setup.
Project maturity is the biggest risk
Last release was January 2017, and the opportunity score is low. There is effectively no active maintenance. Consequences:
composer.lock, document it, and budget a migration path.Config file silently does nothing
peridot.php must return a callable receiving the emitter. Returning null, returning an array, or side-effecting without returning a closure means your listeners/reporters never attach. There is usually no loud error for a malformed config — you just get default behavior. If custom output is missing, check the return statement first.
Scope confusion
$this inside specs is a Peridot scope object, not your test class. Properties set in before/beforeEach persist for the current spec’s tests but are not shared across unrelated specs. Closure variables require explicit use. This trips up developers coming from PHPUnit test classes.
Hook ordering and nesting
before hooks run from outer describe to inner describe; after hooks unwind in reverse. Put cleanup in the matching level. Mixing global bootstrap and per-test database state is the most common source of order-dependent failures.
Focus and skip state leaks into CI
Spec-level focus/skip markers are convenient locally but dangerous if committed. A focused suite can silently reduce CI to one test. Add a guard: grep for focus markers in CI, or run the full suite without focus flags and review reporter counts. Also confirm skipped tests are visible in your chosen reporter.
--grep matches the full description path
The regex applies to the composed description (including parent describe names), not just the leaf text. Anchor patterns (^...) and run a focused file first when debugging unexpected matches.
Reporter output must use the injected output object
Writing directly to echo or STDOUT bypasses formatting and breaks when output is redirected or --no-colors is used. Always use the output abstraction passed to the reporter.
CI exit codes
Verify that a failing spec produces a non-zero exit code with your reporter. Some custom reporter implementations accidentally swallow failures or exit zero from runner.end. A one-time deliberate failure test catches this before it hides real regressions.
File discovery
Files must match the *.spec.php pattern inside the configured spec path. A misplaced file yields “no specs found” rather than an error about that file. If a new spec seems ignored, check the extension and directory before debugging the framework.
Autoloading application code
Spec files are not autoloaded by Magento-style conventions; they are included by the runner. Application classes must be in autoload or autoload-dev in composer.json. If you need a bootstrap (constants, env vars, factories), do it in a listener on runner.start or a dedicated bootstrap file required from peridot.php.
Debugging failing specs
--reporter=spec for detailed output.var_dump/fwrite(STDERR, ...) inside hooks rather than inside assertions.error_reporting settings.beforeEach, subsequent tests may report cascading failures; read the first failure, not the last.Performance
There is no built-in parallelism in core. The historical concurrency plugin adds process isolation complexity and shared-resource hazards (database, ports, temp files). Before reaching for it, move slow fixture creation from beforeEach to before, and isolate integration specs into a separate directory/suite run.
Don’t over-invest in undocumented internals
The event names and reporter interfaces are the stable-ish seams. Classes under Peridot\Core and Peridot\Runner are internal and may change across minor versions. If you extend them directly, pin the version and cover the extension with a smoke test that runs in CI.
Assertion ergonomics
Core Peridot does not give you much beyond the runner and DSL. Standardize early on peridot-php/expect or a native assert()/exception-throwing style, and write it down. Mixed assertion styles make failure output inconsistent and slow down onboarding.
How can I help you explore Laravel packages today?