Test harness and Phalcon bootstrapping for PHPUnit and beyond - the part of Phalcon that catches the bugs.
Talon provides framework-neutral traits (the core), ready-to-extend PHPUnit base classes, and a one-liner bootstrap so any Phalcon project can write unit, integration, and functional tests with minimal boilerplate.
^8.1ext-phalcon C extension (^5) or the phalcon/phalcon PHP implementation (^6). Talon detects whichever is present.composer require --dev phalcon/talon
// tests/bootstrap.php
require __DIR__ . '/../vendor/autoload.php';
use Phalcon\Talon\Settings;
use Phalcon\Talon\Talon;
Talon::boot(Settings::fromEnv());
Need setup hooks (the old loadIni / loadFolders)? Use the bootstrap runner:
use Phalcon\Talon\Bootstrap\Runner;
use Phalcon\Talon\Bootstrap\Stage;
use Phalcon\Talon\Settings;
Runner::for(Settings::fromArray(['root' => __DIR__ . '/..']))
->before(Stage::Environment, fn () => ini_set('memory_limit', '512M'))
->after(Stage::Directories, fn ($settings) => mkdir($settings->outputPath('screens'), 0777, true))
->boot();
use Phalcon\Talon\PHPUnit\AbstractUnitTestCase;
final class CalculatorTest extends AbstractUnitTestCase
{
public function testInternal(): void
{
$this->assertSame(5, $this->callProtectedMethod(new Calculator(), 'add', 2, 3));
}
}
AbstractUnitTestCase gives you callProtectedMethod(), getProtectedProperty(), setProtectedProperty(), invokeMethod(), getNewFileName(), safeDeleteFile(), safeDeleteDirectory(), assertFileContentsContains(), checkExtensionIsLoaded(), and checkPhalconAvailable().
use Phalcon\Talon\PHPUnit\AbstractDatabaseTestCase;
final class UserTest extends AbstractDatabaseTestCase
{
public function testSeeded(): void
{
$this->assertInDatabase('users', ['email' => '[email protected]']);
}
}
The driver comes from the driver env (sqlite, mysql, mariadb, pgsql); credentials come from Settings (env vars by default - see resources/.env.example).
A schema fixture declares one table's DDL per dialect. Talon generates the SQL dumps from them; your tests use the same classes to truncate and populate.
use Phalcon\Talon\Database\Schema\AbstractSchema;
final class WidgetSchema extends AbstractSchema
{
protected string $table = 'widgets';
public function insert(int $id, string $label): int
{
return $this->execute(
'INSERT INTO widgets (id, label) VALUES (:id, :label)',
[':id' => $id, ':label' => $label]
);
}
protected function getStatementsMysql(): array
{
return ['CREATE TABLE widgets (id INT PRIMARY KEY, label VARCHAR(64));'];
}
protected function getStatementsPgsql(): array
{
return ['CREATE TABLE widgets (id INTEGER PRIMARY KEY, label VARCHAR(64));'];
}
protected function getStatementsSqlite(): array
{
return ['CREATE TABLE widgets (id INTEGER PRIMARY KEY, label TEXT);'];
}
}
The three per-dialect methods are abstract on purpose - a new dialect cannot be silently forgotten. There is no mariadb method: MariaDB uses the MySQL dialect.
DROP TABLE yourself; the generator prepends one from the table name.getDependencies() returns the table names that must exist first. Override it when the table carries a foreign key.insert() is yours. The contract covers the lifecycle - create(), drop(), clear() - never the data shape, so each fixture types its own insert signature.talon schema # every dialect
talon schema mysql # one dialect
Configure it with these settings (env vars, or keys in Settings::fromArray()):
| Setting | Meaning |
|---|---|
schema_source |
Directory holding the fixture classes, relative to the project root |
schema_namespace |
Namespace prefix for those classes |
schema_output |
Directory the artifacts are written to, relative to the project root |
schema_pre |
FQCN run before every table - session setup, namespace creation |
schema_post |
FQCN run after every table - closes whatever schema_pre opened |
Each dialect gets its own directory:
schema/mysql/_preSchema.sql always written, even when empty
schema/mysql/users.sql one file per table: its DROP, then its creation statements
schema/mysql/manifest.json load order, dependencies, per-dialect presence
schema/mysql/_postSchema.sql always written, even when empty
The manifest is generated, never hand-edited - if it is wrong, fix a fixture class and regenerate.
Point dump_file at the dialect directory and AbstractDatabaseTestCase loads it on the first connection: pre-schema, the manifest's tables in order, then post-schema.
<env name="dump_file" value="resources/schema/mysql"/>
loadSchema() also still accepts a single flat .sql file, so a project can migrate to the directory format on its own schedule.
Loading the whole schema once and truncating stays the default - fast, ordered, dependency-safe. addTable() is the escape hatch for a test that needs one table rebuilt:
$this->addTable('users');
It is standalone only, and enforced: a table's declared dependencies must already exist, or it throws SchemaDependencyMissing naming the missing one. Call it once per table, dependency first. The strictness is deliberate - schema_pre is live only during the bulk load, so a table with foreign keys that loads fine in bulk can fail standalone on MySQL for reasons nothing at the call site suggests.
The package never owns your container - hand it your configured application:
use Phalcon\Talon\PHPUnit\AbstractFunctionalTestCase;
final class HomeTest extends AbstractFunctionalTestCase
{
protected function appFactory(): callable
{
return fn () => require __DIR__ . '/../app/bootstrap.php'; // returns a configured Application/Micro
}
public function testHome(): void
{
$this->dispatch('/');
$this->assertController('index');
$this->assertResponseContentContains('Welcome');
}
}
For multi-request flows - login, forms, redirects - AbstractBrowserTestCase drives your app in-process (no web server) through a symfony/browser-kit bridge, keeping cookies and the session across requests:
use Phalcon\Talon\PHPUnit\AbstractBrowserTestCase;
final class LoginTest extends AbstractBrowserTestCase
{
protected function appFactory(): callable
{
return fn () => require __DIR__ . '/../app/bootstrap.php';
}
public function testLogin(): void
{
$this->visitPage('/session/login');
$this->fillField('email', '[email protected]');
$this->fillField('password', 'password1');
$this->pressButton('Log In');
$this->assertPageContainsText('Search users');
}
}
Verbs: visitPage, fillField, selectOption, clickLink, pressButton, getCookie/setCookie; assertions: assertPageContainsText / assertPageMissingText. Redirects are followed automatically. Needs symfony/browser-kit + symfony/dom-crawler.
use Phalcon\Talon\PHPUnit\AbstractServicesTestCase;
final class CacheTest extends AbstractServicesTestCase
{
public function testRedis(): void
{
$this->setRedisKey('key', 'value');
$this->assertSame('value', $this->getRedisKey('key'));
}
}
Service tests skip automatically when the backend is unreachable.
use Phalcon\Talon\Traits\ResultSetTrait;
final class ReportTest extends \PHPUnit\Framework\TestCase
{
use ResultSetTrait;
public function testReport(): void
{
$resultset = $this->mockResultSet([$modelA, $modelB]);
$this->assertCount(2, $resultset);
}
}
Override getSettings() in a project base class, or pass Settings::fromArray([...]) to Talon::boot():
Talon::boot(Settings::fromArray([
'root' => dirname(__DIR__),
'db' => [
'mysql' => ['host' => '127.0.0.1', 'port' => 3306, 'dbname' => 'app', 'username' => 'root', 'password' => ''],
'sqlite' => ['dbname' => ':memory:'],
],
]));
vendor/bin/talon fronts PHPUnit per mapped suite:
vendor/bin/talon run # default suite (unit)
vendor/bin/talon run mysql
vendor/bin/talon run mysql pgsql
vendor/bin/talon run all # every mapped suite, sequentially
vendor/bin/talon suites # list mapped suites
With zero configuration, suites are discovered from phpunit*.xml files in the project root and resources/ (phpunit.mysql.xml becomes mysql; phpunit.xml.dist becomes unit, the default). Projects that need php ini flags or env vars declare a talon.php at the project root:
return [
'php' => ['extension=ext/modules/phalcon.so'], // global ini flags, optional
'suites' => [
'unit' => ['config' => 'resources/phpunit.xml.dist'],
'mariadb' => ['config' => 'resources/phpunit.mariadb.xml'],
'mysql' => ['config' => 'resources/phpunit.mysql.xml'],
'pgsql' => ['config' => 'resources/phpunit.pgsql.xml'],
'sqlite' => ['config' => 'resources/phpunit.sqlite.xml'],
],
'default' => 'unit',
];
Per-suite keys: config (required), php (extra ini flags), env (extra env vars) and args (default PHPUnit arguments) - suite entries merge over the global php/env. Options are forwarded to PHPUnit starting at the first option talon does not recognize itself, and everything after -- is always forwarded verbatim:
vendor/bin/talon run unit -- --filter FooTest --testdox
Each suite runs as its own subprocess (per-suite extensions and env vars work), a single suite's exit code is forwarded verbatim, and multiple suites exit with the maximum code after a per-suite summary.
The traits are the core public API and carry no PHPUnit base-class requirement for their non-assertion helpers, so Pest (uses(...)) and other runners can consume them too. Pest and Codeception adapters are planned for a future release.
Talon is developed entirely in Docker - see CONTRIBUTING.md for the full local-development guide. The short version:
cp resources/.env.example .env
sed -i "s/^UID=.*/UID=$(id -u)/;s/^GID=.*/GID=$(id -g)/" .env
docker compose run --rm app composer install # one-time: writes vendor to your checkout
docker compose run --rm app composer test
# or work inside the container:
docker compose up -d && docker compose exec app bash
BSD-3-Clause. See LICENSE.
How can I help you explore Laravel packages today?