Every generated command had the same name
TetherPHP generates console commands with tether make:command. You give it a name, it writes a class into
app/Commands/. Today I found out it had been writing the same command name into every single one.
The stub
Code generation works from stub files with {{className}} placeholders. Here is what the command stub contained:
class {{className}} extends Command
{
// 'php tether tetherphp:command'
public string $command = 'tetherphp:command';
public string $description = '';
}
The class name was substituted. The command name — the thing you actually type to run it — was a literal, left over from whenever the stub was first written.
Generate one command and it works fine, which is why this survived. Generate two and they both claim
tetherphp:command. The console keys its registry on that string, so the second one silently overwrites the first.
One of your commands stops existing and nothing anywhere says so.
Deriving the name instead
The fix is to derive both names from the single argument the developer already provides:
// 'send-emails', 'SendEmails' and 'SendEmailsCommand' all name the same command
$baseName = preg_replace('/Command$/', '', $this->toValidClassName($name));
$className = $baseName . 'Command';
$commandName = $this->toKebabCase($baseName);
Which means all three of these do the sensible thing:
| You type | Class written | Command created |
|---|---|---|
send-welcome-email |
SendWelcomeEmailCommand |
send-welcome-email |
RunNightlyReport |
RunNightlyReportCommand |
run-nightly-report |
DeployCommand |
DeployCommand |
deploy |
That last row matters more than it looks. Stripping a redundant Command suffix stops the generator producing
DeployCommandCommand, which is the kind of thing you only notice after you have committed it.
Pinning the contract
The underlying problem is that a stub placeholder is an interface between two files, and nothing was enforcing it. A placeholder that no generator substitutes gets written literally into the developer's file. So the set of valid placeholders is now a tested contract:
public function testStubsUseOnlyKnownPlaceholders(): void
{
$known = ['{{className}}', '{{commandName}}'];
foreach (['Action', 'Command', 'Domain', 'Responder'] as $stub) {
preg_match_all('/\{\{[a-zA-Z]+\}\}/', $this->stub($stub), $matches);
foreach (array_unique($matches[0]) as $placeholder) {
$this->assertContains($placeholder, $known);
}
}
}
Plus a test asserting the literal tetherphp:command never comes back.
The bug underneath the bug
Chasing this turned up something worse. The tether binary ended like this:
new Console($command)->executeCommand(array_slice($args, 1), []);
executeCommand() returns a status code. Nothing did anything with it. Every invocation of every command exited
0 — a failed generation, an unknown command, and a clean success were indistinguishable to any script or CI job
that tried to check.
exit(new Console($command)->executeCommand(array_slice($args, 1), []));
One word. Now an invalid argument exits 2, an error exits 1, and success exits 0.
There is a principle behind this one. Tooling is part of the framework, not a bolt-on — and a tool that cannot tell you it failed is not finished. Both of these bugs were the same failure repeated: something went wrong and the system said nothing at all.