crysalead/kahlan
Kahlan is a BDD testing framework for PHP inspired by RSpec and Jasmine. It offers a readable describe/it syntax, built-in mocks and stubs, and code coverage support, making expressive unit testing simple to set up for PHP projects.
Kahlan is a BDD-style PHP test framework built around describe() / it() closures and a $this-based spec scope. It is not a PHPUnit drop-in; its distinguishing feature is monkey patching — replacing methods, static methods, final classes, and even built-in functions like time() or file_get_contents() in-place at runtime.
Minimal setup
composer require --dev crysalead/kahlan
vendor/bin/kahlan --init # scaffolds spec/ and kahlan-config.php (if supported by your version)
If --init is unavailable, create spec/ manually and run:
vendor/bin/kahlan --spec=spec --src=src
First spec — put this in spec/CalculatorSpec.php:
<?php
use App\Calculator;
describe('Calculator', function () {
beforeEach(function () {
$this->calculator = new Calculator();
});
it('adds two integers', function () {
expect($this->calculator->add(2, 3))->toBe(5);
});
it('rejects non-numeric input', function () {
expect(function () {
$this->calculator->add('a', 3);
})->toThrow(new InvalidArgumentException());
});
});
Where to look first
$this scope inside beforeEach / it — this is how state is shared between specs.allow() and expect(...)->toReceive() — Kahlan's mocking surface.kahlan-config.php — filters, coverage reporters, and custom CLI behavior.Subject + lifecycle hooks
Use beforeEach to build the subject and $this->subject as a convention. Hooks nest: parent beforeEach runs before child beforeEach, and afterEach runs child-first.
describe('UserService', function () {
beforeEach(function () {
$this->repo = Double::instance(['implements' => UserRepository::class]);
$this->subject = new UserService($this->repo);
});
describe('::register()', function () {
beforeEach(function () {
$this->payload = ['email' => '[email protected]'];
});
it('persists the user', function () {
expect($this->repo)->toReceive('save')->once();
$this->subject->register($this->payload);
});
});
});
Stubs vs. expectations
allow($obj)->toReceive('m')->andReturn($x) — stub, no assertion.expect($obj)->toReceive('m')->once()->with($arg) — assertion on invocation.allow($logger)->toReceive('info')->andReturn(null);
// ...
expect($logger)->toReceive('info')->once()->with('user saved');
Counters include ->once(), ->never(), ->times(3), and ->ordered() when call order matters.
Monkey patching functions and statics
it('uses a frozen clock', function () {
allow('time')->toBe(1_700_000_000);
expect((new Post())->createdAt())->toBe(1_700_000_000);
});
it('short-circuits a static dependency', function () {
allow(\App\Clock::class)->toReceive('now')->andReturn(new DateTimeImmutable('2024-01-01'));
expect((new Report())->generatedAt())
->toEqual(new DateTimeImmutable('2024-01-01'));
});
This is the main reason teams pick Kahlan: no runkit, uopz, or refactoring a final class just to test it.
Doubles for interfaces and abstract collaborators
$double = Double::instance([
'implements' => LoggerInterface::class,
'extends' => AbstractNotifier::class,
]);
$double = Double::instance(['methods' => ['__construct']]); // when you need the real constructor
Prefer Double for interface-heavy design, and allow($instance) when you need to patch a concrete/final implementation.
Data-driven specs
Kahlan has no @dataProvider; generate specs in a loop:
foreach ([[1, 1, 2], [2, 3, 5]] as [$a, $b, $expected]) {
it("adds {$a} + {$b}", function () use ($a, $b, $expected) {
expect((new Calculator())->add($a, $b))->toBe($expected);
});
}
Configuration and CI
kahlan-config.php can return an options array or register filters against the runner. Typical CI invocation:
vendor/bin/kahlan --spec=spec --src=src --reporter=dot --no-colors --coverage=4
Composer script:
"scripts": {
"test": "kahlan --spec=spec --src=src",
"test:coverage": "kahlan --spec=spec --src=src --coverage=4"
}
For coverage filters, use Kahlan\Filter\Filters::apply($this, 'coverage', ...) in kahlan-config.php to plug in the Xdebug driver and an HTML/Clover reporter.
Custom matchers
Implement a matcher class against Kahlan's matcher contract and register it, then call it like a built-in (expect($x)->toBeMyThing()). Wrap domain-specific assertions (money, UUIDs, DTOs) here instead of repeating compound expectations in every spec.
opcache.enable_cli=0 in the test environment.allow('Fqcn') will silently have no effect. Keep patched classes out of early bootstraps, or use Kahlan\box() / Monkey::patch() deliberately.beforeAll. allow() is restored after each it, but a patch installed in beforeAll leaks across every spec in that describe. Prefer beforeEach unless you explicitly want block-level state.toEqual vs toBe. toBe is ===; toEqual is == (structural for arrays, property-based for objects). Use toBe for scalars and identity, toEqual for value objects.toReceive is strict about arguments. ->with($arg) compares values; be explicit about object identity vs. equality, or the assertion fails for reasons unrelated to your code.$this binding is closure-scoped. Spec callbacks are rebound to Kahlan's scope, but a closure created via Closure::bind, a static closure, or a callback passed into a third-party API may lose it. Hoist state into local variables ($calc = $this->calculator;) before handing closures to external code.$this is a Kahlan scope. Add @var annotations or use locals; some teams ship a small stub file.fit / fdescribe silently skips the rest of the suite. Treat a green run with focused specs as a red flag, and grep for them in CI.pending() and skip() do not fail the build. They are useful during TDD, easy to forget before release; report pending specs in the summary and review them.--src narrow so vendor code is not measured.composer.json PHP constraint before upgrading either PHP or Kahlan.TestCase, no setUp()/tearDown(), no PHPUnit assertions, and no native data providers. Porting a large PHPUnit suite is a rewrite; a common pattern is Kahlan for new BDD-style specs alongside an existing PHPUnit suite, sharing the same vendor/autoload.php.class_exists($fqcn, false)), run with --reporter=verbose, and isolate the offending spec with --grep or a focused it before assuming the patch API is wrong.How can I help you explore Laravel packages today?