Symfony Apps skill for Codex
Autowired services, clean Doctrine, reliable Messenger. Codex builds Symfony features with attribute-configured autowiring, request DTOs validated through MapRequestPayload, and voters that stop id-guessing on entity routes. Doctrine code flushes once, fetch-joins what lists render and clears long-running managers, while Messenger handlers receive ids, retry with backoff, land in a failure transport and restart on deploy.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- symfony-apps Installs to ~/.agents/skills/symfony-apps/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install symfony-apps The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project symfony-apps Installs to .agents/skills/symfony-apps/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/symfony-apps.zipunzip -o symfony-apps.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) symfony-apps PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $symfony-apps, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds Symfony 7.x and 8 applications - autowired services and attributes, #[MapRequestPayload] DTO validation, voters for authorization, Doctrine ORM entities, repositories and migrations without N+1 or flush-in-loop mistakes, and Messenger async messages with transports, retries, failure transport and worker deployment. Use when adding a Symfony controller, service, entity or message handler, debugging autowiring or container errors, moving work to Messenger, or reviewing Doctrine usage for performance.
Use it when
- Adding controllers, services, entities or message handlers in Symfony
- Debugging autowiring or container configuration errors
- Moving slow or flaky work into Messenger
- Reviewing Doctrine code for N+1 queries, flush loops or memory growth
Use something else when
- The app is built on Laravel: use laravel-apps
- You need general modern PHP syntax guidance: use php-8-modern
How to check the result
PHPUnit passes with 403 and 422 cases, lint:container and doctrine:schema:validate report no errors, and debug:messenger lists the new handler on the right bus.
The mistake it prevents
Putting Doctrine entities inside Messenger messages; they serialize stale data and arrive detached from the worker's EntityManager.
Done well means: services are small, autowired and configured by attributes; requests map into validated DTOs instead of raw arrays; authorization lives in voters; Doctrine writes flush once per unit of work with fetch joins where lists need relations; and slow or retryable work goes through Messenger with a failure transport and supervised workers.
Inspect first
- Read
AGENTS.md,composer.json(Symfony version: 7.4 LTS or 8.x; Symfony 8 needs PHP 8.4),config/packages/*.yaml(security, doctrine, messenger),config/services.yaml. php bin/console aboutfor environment and versions.php bin/console debug:router,debug:container --show-arguments <id>,debug:autowiring <keyword>,debug:messengerto learn what exists before adding more.
Services
config/services.yaml defaults (autowire: true, autoconfigure: true, resource
../src/) register every class in src/ as a service. Configure the exceptions with
attributes:
final class ErpClient
{
public function __construct(
private readonly HttpClientInterface $erpClient, // named scoped client from framework.http_client
#[Autowire(env: 'ERP_API_KEY')] private readonly string $apiKey,
private readonly LoggerInterface $logger,
) {}
}
| Need | Use |
|---|---|
| Scalar from env | #[Autowire(env: 'NAME')] or %env(NAME)% in YAML |
| Choose among implementations of one interface | #[AsAlias] on the default, or #[Target('name')] on the argument |
| Collect all implementations (strategies) | #[AutoconfigureTag] on the interface + #[AutowireIterator] |
| Lazy expensive dependency | #[Autowire(lazy: true)] |
| Event listener | #[AsEventListener(event: KernelEvents::REQUEST)] |
“Cannot autowire argument” errors: check debug:autowiring for the type, then whether
the argument is a scalar (needs #[Autowire]), an interface with several
implementations (needs an alias or #[Target]), or a class excluded from the resource.
Controllers and DTOs
final class CreateOrderInput
{
public function __construct(
#[Assert\NotBlank, Assert\Length(max: 500)] public readonly string $shippingAddress,
/** @var list<OrderLineInput> */
#[Assert\Count(min: 1, max: 50), Assert\Valid] public readonly array $lines,
) {}
}
#[Route('/api/orders')]
final class OrderController extends AbstractController
{
#[Route('', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function create(
#[MapRequestPayload] CreateOrderInput $input,
#[CurrentUser] User $user,
PlaceOrder $placeOrder,
): JsonResponse {
$order = $placeOrder($user, $input);
return $this->json($order, 201, context: ['groups' => ['order:read']]);
}
#[Route('/{id}', methods: ['GET'])]
#[IsGranted(OrderVoter::VIEW, subject: 'order')]
public function show(Order $order): JsonResponse
{
return $this->json($order, context: ['groups' => ['order:read']]);
}
}
#[MapRequestPayload]deserializes and validates the body; failures return 422 with violations.#[MapQueryString]does the same for query parameters.- Serialize with groups (
#[Groups(['order:read'])]on entity properties) so new fields are not exposed by accident, or return dedicated output DTOs. - Keep controllers thin: one invokable service (
PlaceOrder) per use case.
Voters
final class OrderVoter extends Voter
{
public const VIEW = 'ORDER_VIEW';
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === self::VIEW && $subject instanceof Order;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
return $user instanceof User && $subject->getCustomer() === $user;
}
}
Entity value resolvers load Order $order by id without checking ownership; the voter
is what prevents IDOR. Check access_control in security.yaml covers /api too.
Doctrine
#[ORM\Entity(repositoryClass: OrderRepository::class)]
#[ORM\Index(columns: ['customer_id', 'created_at'])]
class Order
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'orders'), ORM\JoinColumn(nullable: false)]
private User $customer;
#[ORM\OneToMany(targetEntity: OrderLine::class, mappedBy: 'order', cascade: ['persist'], orphanRemoval: true)]
private Collection $lines;
#[ORM\Column(enumType: OrderStatus::class)]
private OrderStatus $status = OrderStatus::Pending;
}
php bin/console make:entity
php bin/console make:migration # review the generated SQL in migrations/
php bin/console doctrine:migrations:migrate
php bin/console doctrine:schema:validate # mapping and DB in sync
- Fetch joins for lists that render relations:
->leftJoin('o.lines', 'l')->addSelect('l'). Paginating a fetch-joined query needs Doctrine’sPaginator, orLIMITcuts rows instead of orders. flush()once per request or command batch, not inside loops. Batch imports:persistN rows,flush(),clear(), repeat; iterate reads withtoIterable().- Long-running processes (workers, commands) accumulate entities in the identity map;
call
$em->clear()between units of work. - Never edit an executed migration; generate a new one.
Messenger
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy: { max_retries: 5, delay: 1000, multiplier: 3, max_delay: 600000 }
failed: 'doctrine://default?queue_name=failed'
routing:
App\Message\SyncOrderToErp: async
buses:
messenger.bus.default:
middleware: [doctrine_ping_connection, doctrine_close_connection]
final class SyncOrderToErp { public function __construct(public readonly int $orderId) {} }
#[AsMessageHandler]
final class SyncOrderToErpHandler
{
public function __construct(private OrderRepository $orders, private ErpClient $erp) {}
public function __invoke(SyncOrderToErp $msg): void
{
$order = $this->orders->find($msg->orderId) ?? throw new UnrecoverableMessageHandlingException('gone');
$this->erp->upsert($order); // idempotent; messages can be delivered twice
}
}
- Messages carry ids and scalars, never entities (serialized entities go stale and detach).
- Dispatch after the database commit: dispatch from a Doctrine
postFlushlistener, or addDispatchAfterCurrentBusStampwhen dispatching from within another handler. - Throw
UnrecoverableMessageHandlingExceptionfor permanent failures to skip retries. - Run workers under systemd or Supervisor:
php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M. After each deploy runmessenger:stop-workersso they restart on new code. - Inspect and replay failures:
messenger:failed:show,messenger:failed:retry.messenger:failed:removedeletes messages permanently: confirm with the user first.
Testing
KernelTestCasewithstatic::getContainer()->get(Service::class)for services.WebTestCase+$client->loginUser($user)for HTTP; assert 403 for another user’s order and 422 for invalid payloads.- In
config/packages/test/messenger.yaml, route transports toin-memory://and assert onstatic::getContainer()->get('messenger.transport.async')->getSent(), or usezenstruck/messenger-testif installed. - Reset the DB per test with DAMA DoctrineTestBundle (transaction rollback) or
Foundry’s
ResetDatabase.
Verification
php bin/phpunit
php bin/console lint:container
php bin/console doctrine:schema:validate
php bin/console debug:messenger
vendor/bin/phpstan analyse # if configured
Gotchas
.envis for defaults; real secrets go in environment variables orsecrets:set(Symfony secrets vault)..env.localis never committed.APP_ENV=prodrequirescache:clear/cache:warmupon deploy; stale container cache produces confusing autowiring errors.- Bidirectional relations need both sides set in code; Doctrine only persists the owning
side (
ManyToOne).
Output template
## Symfony change: <summary>
- Routes: <method path -> controller, security attribute>
- Services: <new services, autowiring attributes>
- Entities/migrations: <changes, migration version>
- Messenger: <message, transport, retry, handler>
- Tests and checks: <phpunit cases, lint:container, schema:validate> What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Symfony Apps"
short_description: "Autowired services, clean Doctrine, reliable Messenger"
default_prompt: "$symfony-apps Add an orders API to our Symfony app with DTO validation and a voter" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.