peridot-php/leo
Leo is a lightweight assertion library for PHP and the Peridot BDD framework. It provides a fluent expect(...)->to syntax with readable matchers for equality, types, arrays, exceptions, and more, helping you write concise, expressive tests without heavy dependencies.
Leo is a BDD-style assertion layer built around a single global function, expect(). It was written for the Peridot spec runner but works standalone anywhere you can autoload a Composer dependency.
composer require --dev peridot-php/leo
Composer wires the expect() function in through the package's files autoload, so once the installer finishes you can call it immediately — no require, no use statement, no bootstrap:
expect(4)->to->equal(4);
expect([1, 2, 3])->to->have->length(3);
expect('laravel')->to->contain('ravel');
expect(null)->not->to->be->null;
The first place to look is vendor/peridot-php/leo/src/Leo/Matcher/. Every assertion Leo ships with is a small class in there implementing a one-method Matcher interface — read a handful and you will understand the whole library in ten minutes. Second stop is Expectation, which owns the fluent grammar (to, be, have, not) and dispatches method calls to the registered matchers.
The first use case to reach for is a quick, readable sanity assertion in a Peridot spec:
describe('Cart', function () {
it('adds items', function () {
$cart = new Cart();
$cart->add(new Item('book', 12));
expect($cart->count())->to->equal(1);
expect($cart->total())->to->equal(12);
});
});
Leo is a replacement for PHPUnit assertions, not a companion — do not try to mix it into a PHPUnit suite expecting assert* methods; keep it inside a Peridot project or use it as a lightweight standalone check in scripts and CLI tooling.
Chain as grammar, terminate as matcher. The words to, be, have, an, a and not are noise words — they return the same Expectation instance. Only a matcher method actually asserts. That means the meaningful line is always the last segment of the chain, and readers scan for it:
expect($user->roles)->to->have->length(2);
expect($response->status())->to->be->equal(404);
expect($payload)->not->to->have->key('password');
Negation is a chain word, not a matcher variant. not flips the result of the next matcher call. It is sticky for that expectation only — build a new expect() for the next assertion.
Custom matchers are the real extension point. Implement Leo\Matcher\Matcher and register it once in a bootstrap file. This is how you keep domain vocabulary out of your specs:
namespace Spec\Matcher;
use Leo\Matcher\Matcher;
class BeExpired implements Matcher
{
public function match($expected, $actual)
{
// return true on success, throw on failure
return $actual->expiresAt < new \DateTime();
}
}
use Leo\Leo;
Leo::registerMatcher('expired', new Spec\Matcher\BeExpired());
Then in specs: expect($token)->to->be->expired();
A matcher should return truthy on success and either return falsy or throw a descriptive exception on failure — check the bundled matchers for which convention each family uses so your custom one behaves consistently in failure output.
Peridot integration is zero-config. Because Leo throws on failed matches, Peridot's it() blocks surface failures as normal spec failures with the matcher's message. Nothing needs to be registered with the runner.
Build a matcher per team vocabulary, not per assertion. Resist writing isValidEmail() style one-offs; instead write matchers that express a shape of expectation (jsonStructure, respondsWith, withinRange) so they compose with not and with have/be chains.
Use it outside tests sparingly. expect() is handy in throwaway scripts and database seeding sanity checks, but for anything production-facing prefer explicit exceptions — Leo failures carry no error codes and are meant for humans reading test output.
Expectation and may need #[\AllowDynamicProperties] shims or an error-suppression layer. Evaluate this before adopting on a modern stack.__get/__call, a misspelled chain word or matcher name can behave unpredictably instead of throwing a clear "no such matcher" error. If an assertion seems to pass suspiciously, check spelling first and add a deliberately failing expectation to confirm the matcher actually fires.expect($x)->to->be->a('string')->and->contain('foo') is not a thing — one expect() per assertion. This is a feature (clear failure attribution) but surprises people coming from fluent PHPUnit/Assert chains.== semantics. If you need strict type checks, write a custom matcher rather than assuming equal() is ===.Expectation. The Matcher contract is where message quality is decided.expect() comes from a Composer files autoload, it is available as soon as vendor/autoload.php runs. If you see "Call to undefined function expect()", you are almost certainly running a script that bypasses Composer's autoloader, or Leo landed in require-dev and your CI installs with --no-dev.expect global casually. Leo defines a global expect() function; another library defining the same name will cause a fatal redeclaration. Check dependencies before adding assertion libraries alongside it.How can I help you explore Laravel packages today?