用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/hpsgd/turtlestack --skill write-handler命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
正在显示 SKILL.md
基于 SOC 职业分类
| name | write-handler |
| description | Write a symfony/messenger command, query, or event handler with constructor injection and bus dispatch. |
| argument-hint | [handler description, e.g. 'CompleteCrawl command handler'] |
| user-invocable | true |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep |
| paths | ["**/*.php"] |
Write a symfony/messenger handler for $ARGUMENTS.
This step is pre-emptive — it discovers what already exists. Do NOT skip ahead to writing the handler and then run grep "to verify". The reconnaissance commands must run first, their output reported in your response, and the design choices below must reflect what you actually found.
Read existing handlers — match the project's patterns:
grep -rn "AsMessageHandler\|MessageHandlerInterface" --include="*.php" src/ | head -20
Identify the bus — which of the three buses does this message belong to?
command.bus — one handler, returns void, mutates aggregate statequery.bus — one handler, returns a result, no side effectsevent.bus — zero or more handlers, returns void, reacts to a fact that happenedIdentify the aggregate or read model — which aggregate does this handler operate on, or which read model does it query?
Identify cascades — does this handler dispatch follow-on messages? Each cascade is its own independent unit of work
Check for existing messages — reuse existing command/event types where appropriate. Don't create CompleteCrawl and MarkCrawlCompleted for the same thing
The message is final readonly class — constructor-promoted, immutable, no logic. Messages live in src/Application/<Context>/ alongside their handlers.
<?php
declare(strict_types=1);
namespace App\Application\Crawl;
use App\Domain\Crawl\CrawlId;
final readonly class CompleteCrawl
{
public function __construct(public CrawlId $crawlId) {}
}
Message rules:
final readonly class — immutable, constructor-promotedCompleteCrawl, not CrawlCompletion)CrawlSummary, not GetCrawlSummary)CrawlId not string $crawlIdA command handler mutates state and returns nothing. Cascades are dispatched, not returned.
<?php
declare(strict_types=1);
namespace App\Application\Crawl;
use App\Domain\Crawl\CrawlAggregate;
use EventSauce\EventSourcing\AggregateRootRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class CompleteCrawlHandler
{
public function __construct(
private AggregateRootRepository $repository,
) {}
public function __invoke(CompleteCrawl $command): void
{
/** @var CrawlAggregate $crawl */
$crawl = $this->repository->retrieve($command->crawlId);
$crawl->complete();
$this->repository->();
}
}
Command handler rules:
final readonly class with __invoke()#[AsMessageHandler(bus: 'command.bus')] attribute binds the handler to the command bus$container->get()A query handler returns a result and has no side effects. Read from a projection or repository, never from event streams.
<?php
declare(strict_types=1);
namespace App\Application\Crawl\Query;
use App\Domain\Crawl\CrawlId;
use App\Infrastructure\ReadModel\CrawlSummaryRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
final readonly class GetCrawlSummary
{
public function __construct(public CrawlId $crawlId) {}
}
final readonly class CrawlSummaryResult
{
public function __construct(
public CrawlId $crawlId,
public int $totalPages,
public int $extractedPages,
public ?\DateTimeImmutable $completedAt,
) {}
}
(: )
{
{}
{
= ->summaries->(->crawlId)
?? (->crawlId);
(
crawlId: ->crawlId,
totalPages: ->totalPages,
extractedPages: ->extractedPages,
completedAt: ->completedAt,
);
}
}
Query handler rules:
final readonly class), never array or mixednull silentlyEvent subscribers react to facts. Multiple subscribers per event. Use them to build read models, send notifications, or trigger downstream commands.
<?php
declare(strict_types=1);
namespace App\Application\Crawl\Projection;
use App\Domain\Crawl\Event\CrawlCompleted;
use App\Domain\Crawl\Event\CrawlStarted;
use App\Infrastructure\ReadModel\CrawlSummaryRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
final readonly class CrawlSummaryProjection
{
public function __construct(private CrawlSummaryRepository $summaries) {}
#[AsMessageHandler(bus: 'event.bus')]
public function whenCrawlStarted(CrawlStarted $event): void
{
$this->summaries->insert(
: ->crawlId,
: (->pageIds),
: ->startedAt,
);
}
(: )
{
->summaries->(->crawlId, ->completedAt);
}
}
Subscriber rules:
#[AsMessageHandler(bus: 'event.bus')] attributewhenCrawlStarted, whenPageExtractedINSERT ... ON CONFLICT or check-then-writeIf you find yourself looping over N items inside a handler, you are doing it wrong. Fan out by dispatching N messages.
// WRONG — processing N items inline
public function __invoke(ExtractAllPages $command): void
{
foreach ($command->pageIds as $pageId) {
$this->extractor->extract($pageId); // BAD: page 47 failing loses pages 1-46
}
}
// CORRECT — fan out one message per item
public function __invoke(ExtractAllPages $command): void
{
foreach ($command->pageIds as $pageId) {
$this->commandBus->dispatch(new ExtractPage($command->crawlId, $pageId));
}
}
Why:
// Fatal errors — let them propagate. Messenger retries per the configured policy
public function __invoke(CompleteCrawl $command): void
{
$crawl = $this->repository->retrieve($command->crawlId);
$crawl->complete(); // may throw CrawlAlreadyCompleted — bubble up
$this->repository->persist($crawl); // may throw on optimistic-concurrency conflict — bubble up
}
// Non-fatal — catch the specific exception, log, do not retry
public function __invoke(NotifyExternalService $command): void
{
try {
$this->client->notify($command->payload);
} catch (HttpClientException $e) {
$this->logger->warning('External notification failed', [
'command_id' => $command->id,
'exception' => $e->getMessage(),
]);
// Swallow — external system is best-effort, don't retry-loop
}
}
Error handling rules:
catch (\Exception $e) — top-level handlers in HTTP/CLI entry points are the only exceptionRetry configuration in messenger.yaml (or the bus config) — every message-bearing transport needs an explicit retry_strategy and a failure_transport:
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000 # 1s
multiplier: 2 # 1s, 2s, 4s
max_delay: 10000 # cap at 10s
failed:
dsn: 'doctrine://default?queue_name=failed'
Tune per message class for messages with different idempotency or cost profiles. Idempotent commands can retry aggressively; commands with side-effects (notifications, payments) should retry less. Document the choice in the messenger config — future-you will not remember why max_retries: 3 instead of 5.
<?php
declare(strict_types=1);
it('completes the crawl and persists', function (): void {
$id = CrawlId::generate();
$crawl = CrawlAggregate::start($id, [PageId::generate()]);
$repository = Mockery::mock(AggregateRootRepository::class);
$repository->shouldReceive('retrieve')->with($id)->andReturn($crawl);
$repository->shouldReceive('persist')->with($crawl)->once();
$handler = new CompleteCrawlHandler($repository);
$handler(new CompleteCrawl($id));
// Behaviour: the aggregate emits CrawlCompleted (verified via the event releaser pattern)
expect($crawl->releaseEvents())->toHaveCount(1);
});
<?php
declare(strict_types=1);
it('dispatches CompleteCrawl through the bus and projects the summary', function (
MessageBusInterface $commandBus,
CrawlSummaryRepository $summaries,
): void {
$id = CrawlId::generate();
// Seed: dispatch StartCrawl first (or seed event stream directly)
$commandBus->dispatch(new StartCrawl($id, [PageId::generate()]));
$commandBus->dispatch(new CompleteCrawl($id));
$summary = $summaries->find($id);
expect($summary)->not->toBeNull()
->and($summary->completedAt)->not->toBeNull();
})->with('messenger_test_container');
Test rules:
MessageBusInterface from the test container — never new CancelSubscriptionHandler($repo). Instantiating the handler directly bypasses the bus, the messenger middleware stack (transaction, retry, outbox), and the routing config — none of which get tested. If your "integration test" instantiates the handler, it is a unit test mislabelledevent.bus; assert on the projectionif ($crawl->status === 'pending') { ... } belongs in the aggregate$this->container->get('something') defeats DI. Constructor injection only\Throwable to keep workers alive — let messenger handle retry and dead-lettering. Don't muffle errorsbeginTransaction() in the handlerDeliver:
final readonly class for command/query/event)final readonly class with __invoke())final readonly class)vendor/bin/pest, exit code)/php-developer:write-aggregate — command handlers operate on aggregates. Write the aggregate first/php-developer:write-feature-spec — the Behat scenario specifies the user-facing behaviour the handler delivers