What You Are Building

The capstone application decided before any code: its routes, its two entities, its three roles, and what runs in the background.

SyntaxRole::Editor->value === 'ROLE_EDITOR'

The capstone is one application, built across this stage: a small publishing service. Anyone may read published articles. A signed-in author writes and edits their own. An editor publishes anything. A JSON API serves the same articles to other programs. Publishing queues an email instead of sending it inside the request. The article list is cached.

Decide the surface first

Three lists, fixed before the first file. Routes: the path, the method, the controller, and who may reach it. Entities: Article and User, with one relationship, Article.author pointing at User. Roles: exactly three.

PathMethodWho
/articlesGETanyone
/articles/newGET, POSTROLE_AUTHOR
/articles/{id}/editGET, POSTthe author, or ROLE_EDITOR
/api/articles/{id}GETanyone
/api/articles/{id}/publishPOSTROLE_EDITOR

Why the lists come first

Every later lesson adds a layer to this same application rather than starting a new toy. A route added afterwards has to fit the table. A field added afterwards needs a migration. Writing the lists down makes those costs visible before you pay them.

What trips people up

Role strings scattered through controllers, templates and configuration, with one of them misspelled. Define them once. In the example below, Role::Editor->value and the literal 'ROLE_EDITOR' are the same string; the literal is the long form you will still write in security.yaml, because YAML cannot read a PHP enum.

Example

Example · php
<?php
// src/Security/Role.php

declare(strict_types=1);

namespace App\Security;

enum Role: string
{
    case User = 'ROLE_USER';
    case Author = 'ROLE_AUTHOR';
    case Editor = 'ROLE_EDITOR';

    /**
     * The roles this role also grants. Mirrors role_hierarchy in security.yaml,
     * which is the file Symfony actually reads.
     *
     * @return list<self>
     */
    public function implies(): array
    {
        return match ($this) {
            self::Editor => [self::Author, self::User],
            self::Author => [self::User],
            self::User => [],
        };
    }

    /** @return list<string> */
    public function allStrings(): array
    {
        // Shorthand and longhand side by side: ->value on an enum case
        // produces exactly the literal string written in the case.
        $roles = [$this->value];

        foreach ($this->implies() as $implied) {
            $roles[] = $implied->value;
        }

        return $roles;
    }
}

// Usage:
// print_r(Role::Editor->allStrings());
// Output:
// Array
// (
//     [0] => ROLE_EDITOR
//     [1] => ROLE_AUTHOR
//     [2] => ROLE_USER
// )

When to use it

  • Scope a portfolio project so it ends with something deployable instead of half a feature
  • Agree the URL and role surface with a teammate before either of you writes a controller
  • Decide which work belongs in the request and which belongs on a queue

More examples

The same hierarchy in configuration

role_hierarchy is what the role voter consults at runtime; the PHP enum exists so application code never types a role string by hand. Keep the two in step - there is no mechanism that checks them against each other. Reach for this file the moment a second role appears.

Example · yaml
# config/packages/security.yaml
security:
    role_hierarchy:
        ROLE_AUTHOR: ROLE_USER
        ROLE_EDITOR: [ROLE_AUTHOR, ROLE_USER]

    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

The route table as the router reports it

Once the controllers exist, this command prints the real route table, which is the only authoritative version of the plan. Run it whenever a URL returns 404 unexpectedly: a route missing here was never registered, which is a different problem from a route that matches and then fails.

Example · bash
$ php bin/console debug:router

 ----------------------------- ------------ ------ ------ -------------------------------
  Name                          Method       Scheme Host   Path
 ----------------------------- ------------ ------ ------ -------------------------------
  app_article_index             GET          ANY    ANY    /articles
  app_article_new               GET|POST     ANY    ANY    /articles/new
  app_article_edit              GET|POST     ANY    ANY    /articles/{id}/edit
  api_article_show              GET          ANY    ANY    /api/articles/{id}
  api_article_publish           POST         ANY    ANY    /api/articles/{id}/publish
 ----------------------------- ------------ ------ ------ -------------------------------

The one relationship, decided up front

Deciding that author is required, and that publishedAt is nullable because an unpublished draft has no date, settles two later arguments at once: the ownership check in the voter and the query that lists published articles. Write these three decisions down before generating anything.

Example · php
<?php
// src/Entity/Article.php (excerpt)

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 180)]
    private string $title = '';

    // Every article has exactly one author, and the column cannot be null.
    #[ORM\ManyToOne(inversedBy: 'articles')]
    #[ORM\JoinColumn(nullable: false)]
    private ?User $author = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $publishedAt = null;
}

Discussion

  • Be the first to comment on this lesson.