DEV Community

Cover image for Symfony Attributes Are Stable. TypeScript Decorators Aren't.
Gabriel Anhaia
Gabriel Anhaia

Posted on

Symfony Attributes Are Stable. TypeScript Decorators Aren't.


Day two on the new TypeScript team. You left Symfony last Friday. You open the new TypeScript repo, scroll to a controller, and expect to see something close to:

#[Route('/users/{id}', methods: ['GET'])]
public function show(int $id): Response { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

What you find instead is one of three different things, depending on which framework the team picked:

// NestJS
@Controller('users')
class UsersController {
  @Get(':id') show(@Param('id') id: number) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode
// Hono
app.get('/users/:id', validate(IdParam), async (c) => { /* ... */ });
Enter fullscreen mode Exit fullscreen mode
// tRPC
export const users = router({
  show: publicProcedure.input(z.object({ id: z.number() })).query(/* ... */),
});
Enter fullscreen mode Exit fullscreen mode

Three styles. One of them looks like Symfony. Two have no decorators in sight. And the one that looks like Symfony is, underneath, sitting on top of a TypeScript feature that is still finishing the spec.

The syntax #[Foo] and @Foo look like cousins. The contracts are very different.

What you have in PHP: a stable, finished feature

PHP attributes shipped in 8.0 in November 2020 (PHP RFC: Attributes V2). Symfony adopted them starting with 5.2 and made them the default in 6.x and 7.x. By 2026, attributes are the default across the Symfony skeleton, the official documentation, and every modern framework guide for routing, validation, serialization, and DI configuration. Mature in the way only a five-year-old language feature can be.

Concretely, here is what a single Symfony controller method looks like in production:

<?php

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;

final class UsersController extends AbstractController
{
    #[Route('/users/{id}', name: 'users_show', methods: ['GET'])]
    public function show(
        int $id,
        #[MapQueryParameter] #[Assert\Range(min: 1, max: 100)] int $limit = 20,
    ): Response {
        return $this->json(['id' => $id, 'limit' => $limit]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Five things are happening with attributes here:

  1. #[Route(...)] registers the URL. No YAML, no config file.
  2. #[MapQueryParameter] reads ?limit=... and binds it to $limit.
  3. #[Assert\Range(...)] is a parameter-level constraint the validator picks up.
  4. The class is autowired via the DI container.
  5. None of it requires a framework flag. It is just PHP.

Attributes also drive serialization (#[Groups]), Doctrine entities (#[ORM\Entity], #[ORM\Column]), API Platform resources (#[ApiResource]), and Messenger handlers (#[AsMessageHandler]). The pattern is consistent across the ecosystem because the language feature is consistent.

Two underrated facts about PHP attributes:

  • They target classes, properties, methods, parameters, constants, and functions.
  • They are inert. An attribute does not run unless something calls ReflectionClass::getAttributes(). The framework reads them; the language does not auto-execute anything.

TypeScript: stage 3, with gaps

TC39 decorators are at stage 3 as of April 2026 (tc39/proposal-decorators). Stage 3 means the spec is finalised pending implementation feedback; it does not mean every runtime ships it natively. TypeScript was the first to ship official support for the stage-3 form in TypeScript 5.0 in March 2023 (Announcing TypeScript 5.0).

Stage 3 decorators target classes, methods, accessors (getter/setter separately), fields, and auto-accessors (the new accessor keyword).

What stage 3 does not include is the one PHP devs reach for first: parameter decorators. The proposal text is explicit: "This proposal does not include parameter decorators, but they may be provided by future built-in decorators." TypeScript's announcement post makes the same point: "The new decorators proposal is not compatible with --emitDecoratorMetadata, and it does not allow decorating parameters."

Re-read the Symfony controller above. One of those five things is parameter-level (#[MapQueryParameter] #[Assert\Range] on $limit). In stage-3 TypeScript decorators, there is no syntactic place for that. You can decorate the method, the class, the field — not the parameter.

A small example of what stage-3 decorators do look like:

function logged<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext,
) {
  const name = String(context.name);
  return function (this: This, ...args: Args): Return {
    console.log(`-> ${name}`);
    const result = target.call(this, ...args);
    console.log(`<- ${name}`);
    return result;
  };
}

class Service {
  @logged greet(name: string) { return `hi ${name}`; }
}
Enter fullscreen mode Exit fullscreen mode

The context parameter is the new piece. It carries the kind ("method", "class", "field", etc.), the name, an addInitializer hook, and access helpers. Structurally cleaner than the legacy implementation; today, less expressive: no parameter decorators, no decorator metadata in the legacy sense, no automatic emit of constructor parameter types.

NestJS in 2026, honestly

NestJS is the TypeScript framework whose surface feels most like Symfony, and the one PHP devs always point to. The honest answer: as of the latest Nest release at the time of writing, a fresh nest new scaffold still ships experimentalDecorators and emitDecoratorMetadata in its tsconfig — the legacy proposal, not the stage-3 form. The framework's tracking issue (#11414 — Support TC39 decorators in TypeScript 5.0) and the official starter scaffold both confirm it.

A fresh Nest tsconfig.json looks like this:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "target": "ES2023"
  }
}
Enter fullscreen mode Exit fullscreen mode

Both flags are required. experimentalDecorators opts into the legacy proposal; emitDecoratorMetadata is what makes Nest's DI work — it is how runtime parameter types end up in Reflect.getMetadata("design:paramtypes", ...). Stage-3 decorators do not support either flag, by design.

This is the gap that hits a Symfony dev in the chest. The framework that looks most like Symfony is built on a TypeScript feature that has not moved to the new spec because the new spec dropped the two things Nest depends on most: parameter decoration and emitDecoratorMetadata. Without parameter decoration, you cannot write @Param('id') id: string. Without emitDecoratorMetadata, the DI container cannot read constructor parameter types at runtime.

This is not a knock on Nest. It is a working framework with a real ecosystem. It is the honest description of what you sign up for: you pin yourself to legacy decorators for the foreseeable future, and you bet that TypeScript keeps supporting that flag (the team has signalled they will). A reasonable bet. Not the same bet as Symfony attributes, where the language feature is the language.

Outside Nest: composition won

Outside Nest, the big shift in TypeScript backend frameworks over the last three years is away from decorators entirely.

Hono uses plain functions and middleware composition (validation guide):

import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';

const Params = z.object({ id: z.coerce.number().int().min(1) });
const Query  = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

const app = new Hono();

app.get(
  '/users/:id',
  zValidator('param', Params),
  zValidator('query', Query),
  async (c) => {
    const { id }    = c.req.valid('param');
    const { limit } = c.req.valid('query');
    return c.json({ id, limit });
  },
);
Enter fullscreen mode Exit fullscreen mode

No decorators, no annotations. The route is a function call. The validator is a function call. Both compose into the handler chain by being passed in. Types are inferred from the Zod schemas, so c.req.valid('param') is precisely typed without reflection.

tRPC goes further. Routers are objects, procedures are values, and the input type comes from the schema:

import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  showUser: t.procedure
    .input(z.object({ id: z.number().int().min(1) }))
    .query(({ input }) => ({ id: input.id })),
});

export type AppRouter = typeof appRouter;
Enter fullscreen mode Exit fullscreen mode

The client gets the router type and end-to-end inference for free. Nothing to decorate because there is no class.

In the PHP world, attributes won. In the TypeScript world, the trend is composition over decoration: middleware functions, schema-first validation, type inference instead of metadata reflection. Decorators are still a legitimate tool. They read well for memoisation, observability, and transaction boundaries. They are not the default reach for routing or validation anymore.

Side by side: a route handler that does the same job

Two solutions to the same problem, side by side.

Symfony, with attributes

<?php

use App\Service\UserService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;

final class UsersController extends AbstractController
{
    public function __construct(private UserService $users) {}

    #[Route('/users/{id}', name: 'users_show', methods: ['GET'])]
    public function show(
        #[Assert\Positive] int $id,
        #[MapQueryParameter] #[Assert\Range(min: 1, max: 100)] int $limit = 20,
    ): JsonResponse {
        return $this->json($this->users->find($id, $limit));
    }
}
Enter fullscreen mode Exit fullscreen mode

The route, the parameter binding, the validation, and the DI all live as attributes on the class and the parameters. The body of the method is one line of business code. This is where Symfony attributes shine: the noise is annotation, the signal is the call.

Hono, with composition

import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { UserService } from './user-service';

const Params = z.object({ id: z.coerce.number().int().positive() });
const Query  = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

export function buildApp(users: UserService) {
  const app = new Hono();

  app.get(
    '/users/:id',
    zValidator('param', Params),
    zValidator('query', Query),
    async (c) => {
      const { id }    = c.req.valid('param');
      const { limit } = c.req.valid('query');
      return c.json(await users.find(id, limit));
    },
  );

  return app;
}
Enter fullscreen mode Exit fullscreen mode

Same job. Different shape. The route is a function; the validators are function calls in the middleware chain; the DI is a closure over users. The schema lives in code, not on the parameters. The type of id and limit comes from Zod, not from reflection on a constructor.

What you trade

PHP wins on cleanliness for routing and serialization. There is less code on the page in the Symfony version. The validation lives next to the parameter it validates.

TypeScript wins on type-driven schemas. Zod (and its newer relatives like Effect Schema and Valibot) gives you a single object that is both a runtime validator and a type. The c.req.valid('param') return value is precisely typed because the schema is the source of truth. Symfony has nothing equivalent in the language; the validator constraints describe runtime behaviour, the property type system describes static behaviour, and the two have to be kept in sync.

Neither side wins outright. The trade is real.

What changes in 2026 and what does not

For TypeScript developers, the spec story for the next year or two:

  • Stage 3 has been the status for several years. The proposal has not advanced to stage 4, and no champion has signalled a near-term promotion. Stage 4 means the spec is shipping in two or more independent engines. Engine support is the gating factor.
  • TypeScript 5.0+ ships the stage-3 syntax and semantics. You can write decorators today against the new model and get type checks, with no experimental flag for the new syntax.
  • Parameter decorators are a separate, future proposal. Not on the stage-3 train. If parameter decoration ever lands, it will land later and probably with different ergonomics than what the legacy decorators gave you.
  • NestJS will keep using legacy decorators until either parameter decoration arrives in stage-3 territory, or the framework rewrites its DI to not depend on emitDecoratorMetadata. The TypeScript team's stated position is that the legacy flag stays.
  • Outside Nest, the new style is "no decorators by default." Hono, Elysia, tRPC, Drizzle, the Bun and Deno runtime APIs — all composition-first.

For PHP developers, attributes keep getting better. PHP 8.4 added first-party support for lazy objects via ReflectionClass::newLazyGhost/newLazyProxy. PHP 8.5 brought the pipe operator (|>) and further refinements to visibility.

The mindset shift, briefly

If you are coming from Symfony, the useful thing to internalise: in PHP, attributes are how the framework reaches into your code. In TypeScript, the trend is the inverse — your code reaches into the framework as a value. You compose middleware. You pass schemas around. You return router objects. The framework is a library you call, not a runtime that scans you.

This is partly because TypeScript erases at compile time. There is no runtime type system to reflect on without emitDecoratorMetadata. So the ecosystem leaned into structural typing and inferred types. A Zod schema is a type. The router you export becomes the contract the client compiles against, and the handler signature carries the contract the route enforces.

Once that lands, the absence of parameter decorators stops feeling like a missing feature and starts feeling like a different shape of solution. You do not annotate the parameter; you validate the input. You do not decorate the constructor; you pass the dependency in.

You will still miss #[Route] for the first month. Then you will write your first end-to-end-typed tRPC procedure, change the schema, and watch the client compile error tell you exactly which call sites need updating, and the trade will start to make sense.

PHP attributes are mature and stage-3 decorators ship today, with real use cases in logging, memoisation, and transaction boundaries. The gotcha is parameter decoration, not the syntax.

If you want the long version: migration patterns for every PHP framework attribute with a TypeScript counterpart, Symfony controllers vs Hono and tRPC, Doctrine's ORM attributes meeting Drizzle's schema-as-code. PHP to TypeScript in The TypeScript Library is written for that reader.


If this was useful

The TypeScript Library is a 5-book set. If you are coming from Symfony and PHP 8+, the natural path is #4 first, then #1 or #2 for depth.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

  • #1 — TypeScript Essentials — entry point. Types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, the browser.
  • #2 — The TypeScript Type System — the deep dive. Generics, mapped and conditional types, infer, template literals, branded types.
  • #3 — Kotlin and Java to TypeScript — bridge for JVM developers. Variance, null safety, sealed-to-unions, coroutines to async/await.
  • #4 — PHP to TypeScript — bridge for PHP 8+ developers. Sync to async paradigm, generics, discriminated unions, the attributes-vs-decorators chapter this post sketches.
  • #5 — TypeScript in Production — the production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

Books #1 and #2 are the core path. Book #4 substitutes for #1 if you are coming from PHP. Book #5 is for anyone shipping TypeScript at work.

Hermes IDE is an IDE for developers who ship with Claude Code and other AI coding tools — written in TypeScript, with the patterns this collection covers.

Top comments (0)