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.

  • Backend & APIs
  • $symfony-apps
  • 238 lines
  • 2 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- symfony-apps

Installs to ~/.agents/skills/symfony-apps/ for every project on this machine.

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

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.

~/.agents/skills/symfony-apps/SKILL.md

--- name: symfony-apps description: Builds Symfony 7.x and 8 applications - autowired services and attributes, #[MapRequestPayload] DTO validation, voters for authorization, Doctrine… license: MIT ---

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 about for environment and versions.
  • php bin/console debug:router, debug:container --show-arguments <id>, debug:autowiring <keyword>, debug:messenger to 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,
    ) {}
}
NeedUse
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’s Paginator, or LIMIT cuts rows instead of orders.
  • flush() once per request or command batch, not inside loops. Batch imports: persist N rows, flush(), clear(), repeat; iterate reads with toIterable().
  • 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 postFlush listener, or add DispatchAfterCurrentBusStamp when dispatching from within another handler.
  • Throw UnrecoverableMessageHandlingException for 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 run messenger:stop-workers so they restart on new code.
  • Inspect and replay failures: messenger:failed:show, messenger:failed:retry. messenger:failed:remove deletes messages permanently: confirm with the user first.

Testing

  • KernelTestCase with static::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 to in-memory:// and assert on static::getContainer()->get('messenger.transport.async')->getSent(), or use zenstruck/messenger-test if 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

  • .env is for defaults; real secrets go in environment variables or secrets:set (Symfony secrets vault). .env.local is never committed.
  • APP_ENV=prod requires cache:clear/cache:warmup on 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.yamlgenerated from this page

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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem