The TestHelper plugin provides a flexible linter system for running project-specific code quality checks. Unlike general-purpose tools (phpcs, phpstan, rector), these linters are designed for quick validation of project-specific conventions that don't fit into standard static analysis tools.
- Cake rules: Enforce CakePHP conventions and best practices (plugin based tasks)
- Project-specific rules: Enforce conventions unique to your codebase (extension level)
- Fast execution: Simple checks that run in seconds
- Easy to extend: Create custom tasks without complex tool configuration
- Integration-friendly: Can be run in CI pipelines alongside other quality tools
Run all available linter tasks:
bin/cake linterList available tasks:
bin/cake linter --listRun a specific task:
bin/cake linter --task use-orm-queryCheck specific paths:
bin/cake linter src/,tests/Check a specific plugin:
bin/cake linter --plugin Board
bin/cake linter -p BoardCheck all loaded plugins:
bin/cake linter --plugin all
bin/cake linter -p allCheck plugins with wildcard pattern:
bin/cake linter --plugin "My*" # Match MyPlugin, MyOtherPlugin, etc.
bin/cake linter -p "*Helper" # Match IdeHelper, TestHelper, etc.Show paths being checked and use absolute file paths (verbose mode):
bin/cake linter -vVerbose mode shows:
- Paths being checked
- Full absolute file paths instead of relative paths (for better terminal/CLI clickability)
- Which plugin(s) are being checked when using
--pluginoption
Check specific plugins or all loaded plugins:
# Check a specific plugin
bin/cake linter --plugin Board
# Check all loaded plugins
bin/cake linter --plugin all
# Combine with task filter
bin/cake linter --plugin Board --task duplicate-template-annotations
# Combine with verbose mode
bin/cake linter -p all -vHow it works:
- Each task's default paths are prefixed with the plugin directory
- Example: For plugin
Board, if a task checkstemplates/andsrc/Controller/, it will check:plugins/Board/templates/plugins/Board/src/Controller/
--plugin PluginNamechecks one specific plugin--plugin allchecks all loaded app plugins (excludes vendor plugins)--plugin "Pattern*"uses wildcard matching withfnmatch()(e.g.,"My*","*Helper")- Only app plugins are included - vendor plugins are automatically excluded
- If a plugin is not loaded or pattern matches nothing, shows error with available plugins
- Tasks can opt-out of plugin mode by returning
falsefromsupportsPluginMode()- they will be skipped when--pluginis used
Some linter tasks support automatic fixing of detected issues:
bin/cake linter --fixRun auto-fix on a specific task:
bin/cake linter --task array-urls-in-tests --fixSee task list below to see which tasks support auto-fix
For running in CI environments where test database connections are configured:
bin/cake linter --ciThis enables CI mode which aliases test database connections (e.g., test to default, test_custom to custom) to ensure the linter works correctly in CI pipelines where only test databases are available.
0- All checks passed, no issues found1- Issues were found
The plugin includes nine default linter tasks that are active by default:
Checks controller files for string URLs in redirect() - enforces array format.
- Checks:
src/Controller/directory - Purpose: Enforce CakePHP routing array format instead of string URLs in controllers
- Auto-fix: ✅ Supported (simple URLs only)
Example violation:
return $this->redirect('/dashboard/index');
return $this->redirect('/articles/index?status=published');Auto-fixed to:
return $this->redirect(['controller' => 'Dashboard', 'action' => 'index']);
return $this->redirect(['controller' => 'Articles', 'action' => 'index', '?' => ['status' => 'published']]);Limitation: Concatenated URLs (e.g., $this->redirect('/path/' . $variable) or $this->redirect('/path?id=' . $user->id)) are not auto-fixable and will be skipped to avoid breaking code. These need to be manually converted.
Configuration: You can allow specific string URLs that should not trigger warnings:
// config/app.php
return [
'TestHelper' => [
'Linter' => [
'ArrayUrlsInControllers' => [
'allowedStringUrls' => ['/'],
],
],
],
];Checks test files for string URLs in get(), post(), and assertRedirect() - enforces array format.
- Checks:
tests/TestCase/Controller/directory - Purpose: Enforce CakePHP routing array format instead of string URLs in tests
- Auto-fix: ✅ Supported (simple URLs only)
Example violation:
$this->get('/suppliers/view/123');
$this->post('/users/login?user=x');
$this->assertRedirect('/dashboard/index');Auto-fixed to:
$this->get(['controller' => 'Suppliers', 'action' => 'view', 123]);
$this->post(['controller' => 'Users', 'action' => 'login', '?' => ['user' => 'x']]);
$this->assertRedirect(['controller' => 'Dashboard', 'action' => 'index']);Limitation: Concatenated URLs (e.g., $this->get('/path/' . $variable) or $this->assertRedirect('/path?param=' . $value)) are not auto-fixable and will be skipped to avoid breaking code. These need to be manually converted.
Configuration: You can allow specific string URLs that should not trigger warnings:
// config/app.php
return [
'TestHelper' => [
'Linter' => [
'ArrayUrlsInTests' => [
'allowedStringUrls' => ['/'],
],
],
],
];Detects deprecated $options array in find() calls - use named parameters instead.
- Checks:
src/,tests/directories - Purpose: Enforce spread operator for find() options in CakePHP 5.x
- Auto-fix: ✅ Supported
Example violation:
$query->find('all', $options);
$query->find('list', ['conditions' => ['active' => true]]);Auto-fixed to:
$query->find('all', ...$options);
$query->find('list', ...['conditions' => ['active' => true]]);Detects duplicate @var annotations for the same variable in template files.
- Checks:
templates/directory - Purpose: Prevent duplicate variable annotations that can cause confusion and inconsistencies
Example violation:
<?php
/**
* @var array<\Tickling\Model\Entity\TicklishSpot> $ticklishSpots
* @var mixed $ticklishSpots
*/
?>Should be:
<?php
/**
* @var array<\Tickling\Model\Entity\TicklishSpot> $ticklishSpots
*/
?>Ensures template variables have specific type annotations, not mixed.
- Checks:
templates/directory - Purpose: Enforce proper type hints in template files for better IDE support and type safety
Example violation:
<?php
/**
* @var mixed $user
*/
?>Should be:
<?php
/**
* @var \App\Model\Entity\User $user
*/
?>Checks for incorrect generic Query imports which should use specific query types like SelectQuery
- Checks:
src/,tests/directories - Purpose: Enforce specific Query class imports for CakePHP 5.x
Example violations:
use Cake\Database\Query;
use Cake\ORM\Query;Should be:
use Cake\ORM\Query\SelectQuery;Note: Not auto-fixed here, if found, use upgrade tool to auto-fix.
Flags deprecated AbstractMigration and AbstractSeed usage, recommending BaseMigration and BaseSeed
- Checks:
config/Migrations/,config/Seeds/directories - Purpose: Ensure migrations use non-deprecated base classes
Example violation:
use Migrations\AbstractMigration;
class CreateUsersTable extends AbstractMigrationShould be:
use Migrations\BaseMigration;
class CreateUsersTable extends BaseMigrationNote: Not auto-fixed here, if found, use upgrade tool to auto-fix.
Validates that controller test methods contain only one get() or post() call
- Checks:
tests/TestCase/Controller/directory - Purpose: Enforce test isolation - each test method should test a single request
Example violation:
public function testUserFlow(): void
{
$this->get('/users/login');
$this->post('/users/login', ['username' => 'test']); // Second request - violation!
}Should be:
public function testLoginPage(): void
{
$this->get('/users/login');
}
public function testLoginSubmit(): void
{
$this->post('/users/login', ['username' => 'test']);
}Detects postLink() calls within <form> tags and ensures block => true parameter is set to prevent nested forms.
- Checks:
templates/directory - Purpose: Prevent HTML validation errors from nested forms
- Auto-fix: ✅ Supported
Example violation:
<?= $this->Form->create($entity) ?>
<?= $this->Form->postLink('Delete', ['action' => 'delete', $entity->id]) ?>
<?= $this->Form->end() ?>Auto-fixed to:
<?= $this->Form->create($entity) ?>
<?= $this->Form->postLink('Delete', ['action' => 'delete', $entity->id], ['block' => true]) ?>
<?= $this->Form->end() ?>Why this matters: By default, postLink() generates its own <form> element. When used inside another form, this creates invalid nested forms. Setting 'block' => true makes postLink() output the form to a view block instead, which prevents nesting issues.
Create your linter task in src/Command/Linter/Task/:
<?php
declare(strict_types=1);
namespace App\Command\Linter\Task;
use Cake\Console\ConsoleIo;
use TestHelper\Command\Linter\AbstractLinterTask;
class MyCustomTask extends AbstractLinterTask
{
/**
* Task name (used for --task option)
*/
public function name(): string
{
return 'my-custom-task';
}
/**
* Task description (shown in --list)
*/
public function description(): string
{
return 'Check for custom project conventions';
}
/**
* Default paths to check
*/
public function defaultPaths(): array
{
return ['src/', 'tests/'];
}
/**
* Whether this task supports auto-fix (optional, defaults to false)
*/
public function supportsAutoFix(): bool
{
return true; // Set to true if your task can auto-fix issues
}
/**
* Whether this task can run in plugin mode (optional, defaults to true)
*/
public function supportsPluginMode(): bool
{
return false; // Set to false if task should only run at app level
}
/**
* Run the linter task
*
* @param \Cake\Console\ConsoleIo $io Console IO
* @param array<string, mixed> $options Options including paths, fix mode
* @return int Number of issues found
*/
public function run(ConsoleIo $io, array $options = []): int
{
$paths = $options['paths'] ?? $this->defaultPaths();
$files = $this->getFiles($paths, '*.php');
$verbose = $options['verbose'] ?? false;
$fix = $options['fix'] ?? false; // Check if auto-fix is enabled
$issues = 0;
foreach ($files as $file) {
$content = file_get_contents($file);
if ($content === false) {
continue;
}
// Your custom validation logic here
$lines = explode("\n", $content);
$modified = false;
foreach ($lines as $lineNumber => $line) {
// Example: Check for TODO comments
if (preg_match('/TODO:/i', $line)) {
$this->outputIssue(
$io,
$file,
$lineNumber + 1,
'TODO comment found',
trim($line),
$verbose
);
$issues++;
// Auto-fix example: Remove TODO comments
if ($fix) {
$lines[$lineNumber] = preg_replace('/TODO:/i', 'DONE:', $line);
$modified = true;
$io->verbose(' Fixed: ' . trim($lines[$lineNumber]));
}
}
}
// Write back to file if modified
if ($modified) {
file_put_contents($file, implode("\n", $lines));
}
}
return $issues;
}
}Tasks in App\Command\Linter\Task\ are not auto-discovered. You must register them in configuration:
// config/app.php
return [
'TestHelper' => [
'Linter' => [
'tasks' => [
\App\Command\Linter\Task\MyCustomTask::class,
],
],
],
];bin/cake linter --task my-custom-taskConfigure tasks in config/app.php:
return [
'TestHelper' => [
'Linter' => [
'tasks' => [
// Add custom tasks (simple list)
\App\Command\Linter\Task\MyCustomTask::class,
\App\Command\Linter\Task\AnotherTask::class,
// Disable default tasks (associative array with false)
\TestHelper\Command\Linter\Task\UseOrmQueryTask::class => false,
\TestHelper\Command\Linter\Task\SingleRequestPerTestTask::class => false,
],
],
],
];- Default tasks are always loaded unless explicitly disabled with
=> false - Custom tasks must be registered to be available
- Simple list format (
[Task::class]) adds tasks - Associative format (
[Task::class => false]) disables tasks
The AbstractLinterTask base class provides helpful methods:
Scan directories for files matching a pattern:
$files = $this->getFiles(['src/', 'tests/'], '*.php');Report an issue with file path, line number, and context:
$this->outputIssue(
$io,
$file, // File path
$lineNumber, // Line number (1-indexed, use 0 if no line is possible to set)
'Issue description',
'Code context', // Optional: the problematic code
$verbose, // Optional: whether to show full absolute paths (from options)
);public function run(ConsoleIo $io, array $options = []): int
{
$paths = $options['paths'] ?? $this->defaultPaths();
$files = $this->getFiles($paths, '*.php');
$issues = 0;
$deprecated = [
'mysql_query',
'ereg',
'split',
];
foreach ($files as $file) {
$content = file_get_contents($file);
$lines = explode("\n", $content);
foreach ($lines as $lineNum => $line) {
foreach ($deprecated as $func) {
if (preg_match("/\b{$func}\s*\(/", $line)) {
$this->outputIssue(
$io,
$file,
$lineNum + 1,
"Deprecated function '{$func}' found",
trim($line)
);
$issues++;
}
}
}
}
return $issues;
}public function run(ConsoleIo $io, array $options = []): int
{
$paths = $options['paths'] ?? $this->defaultPaths();
$files = $this->getFiles($paths, '*.php');
$issues = 0;
foreach ($files as $file) {
$basename = basename($file, '.php');
// Controller files must end with "Controller"
if (str_contains($file, '/Controller/') && !str_ends_with($basename, 'Controller')) {
$this->outputIssue(
$io,
$file,
1,
"Controller file must end with 'Controller' suffix",
"Found: {$basename}.php"
);
$issues++;
}
}
return $issues;
}public function run(ConsoleIo $io, array $options = []): int
{
$paths = $options['paths'] ?? $this->defaultPaths();
$files = $this->getFiles($paths, '*.php');
$issues = 0;
foreach ($files as $file) {
$content = file_get_contents($file);
// Find all public methods
preg_match_all('/public function (\w+)\(/', $content, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[1] as $match) {
$methodName = $match[0];
$position = $match[1];
// Check if there's a docblock before this method
$beforeMethod = substr($content, 0, $position);
if (!preg_match('/\/\*\*.*?\*\/\s*$/s', $beforeMethod)) {
$line = substr_count($beforeMethod, "\n") + 1;
$this->outputIssue(
$io,
$file,
$line,
"Public method '{$methodName}()' missing docblock",
);
$issues++;
}
}
}
return $issues;
}Add to your CI pipeline to enforce code quality:
# Run for app and all plugins/*
- name: Run Linter
run: bin/cake linter --ci && bin/cake linter --ci -p allThe --ci flag ensures test database connections are properly aliased for CI environments.
And for developers as quick-check in composer scripts:
{
"scripts": {
"check": [
"phpcs",
"phpstan",
"bin/cake linter"
"bin/cake linter -p all"
],
"lint": [
"bin/cake linter",
"bin/cake linter -p all"
],
}
}If you get "Task not found", ensure:
- Task class is registered in
TestHelper.Linter.tasksconfig - Task implements
LinterTaskInterface - Task is in correct namespace
If verbose mode shows no files:
bin/cake linter -vCheck that:
- Default paths exist in your project
- Paths are correct (relative to ROOT)
- File pattern matches files (
*.phpby default)
If linting is slow:
- Limit paths checked:
bin/cake linter src/Controller/ - Check fewer files with specific task:
--task single-request-per-test - Consider caching results or running specific tasks in CI
- Keep tasks focused: Each task should check one specific thing
- Provide context: Use
outputIssue()with code context for better debugging - Use verbose mode: Document paths in verbose output
- Fast execution: Aim for tasks that complete in seconds
- Clear names: Use descriptive task names (
use-orm-query, notquery-check) - Document rules: Add clear descriptions explaining what each task checks
- Test your tasks: Write unit tests for custom linter tasks
- CI integration: Run linters in CI to catch issues early
- Main README
- PHPStan - For complex static analysis
- Rector - For complex migration tooling
