Skip to main content

Command Palette

Search for a command to run...

Build a Real-World Symfony App from First Principles to Production

Updated
31 min read
Build a Real-World Symfony App from First Principles to Production

Audience: Intermediate PHP devs (comfortable with OOP, Composer, basic MVC) who are new/rusty with Symfony
OS Assumptions: macOS/Linux primary; Windows notes included (PowerShell + WSL2)
Target PHP & Symfony: PHP 8.2+ and Symfony 7.3.x (current stable as of 2025). Symfony 7.3 requires PHP ≥ 8.2 and is the current stable per the official releases page. Symfony
Frontend tooling: Twig + Symfony UX/Stimulus using AssetMapper (default in modern Symfony). Symfony+1
Reading Time: ~2.5–3.5 hours, hands-on


What you’ll build

TaskForge - a pragmatic project & task manager with:

  • Auth: registration, login, email verification, password reset; roles (ROLE_USER, ROLE_ADMIN) protected by voters

  • Domain: Projects & Tasks (+ labels, attachments, activity log)

  • UI: Twig + Stimulus micro-interactions

  • API: Minimal JSON API for Tasks (list/create/update) with Serializer groups, DTOs, Validation

  • Background jobs: welcome email + due-soon reminders using Messenger (Doctrine transport via DB) Symfony

  • Search & Pagination: by project, status, priority, due date

  • Uploads: safe file uploads & protected downloads (Nginx X-Accel-Redirect)

  • Caching: HTTP (ETag/Last-Modified) + app cache

  • Testing: unit + functional (HTTP) + a minimal repository test

  • Prod Docker: Nginx + PHP-FPM + PostgreSQL; env vars; migrations on deploy


References (official docs you’ll lean on)

  • Releases & versions: Symfony releases timeline (stable 7.3; PHP ≥ 8.2) Symfony

  • Security: Authentication & authorization (SecurityBundle) Symfony

  • Mailer: Sending email (Mailer & Mime) Symfony

  • Messenger: Async messages & transports (Doctrine DSN, Redis, AMQP) Symfony

  • Serializer: Using serializer & groups for APIs Symfony

  • UX/Stimulus & AssetMapper: official pages Symfony UX+1

Version note: If you’re on Symfony 7.2, everything still works with tiny diffs; upgrade to 7.3 when possible (7.2 is unmaintained since Jul 2025). Symfony


Table of Contents

  1. Introduction & Outcomes

  2. Environment Setup

  3. Bootstrap the Symfony App

  4. Domain & Database Design

  5. Authentication & Authorization

  6. CRUD for Projects & Tasks (Web UI)

  7. File Uploads & Attachments

  8. Activity Logging

  9. Messaging & Background Jobs

  10. Caching & Performance

  11. Minimal JSON API

  12. Testing

  13. Production Readiness & Deployment

  14. Troubleshooting Guide

  15. Wrap-Up, Next Steps & Resources
    Appendices: A) Cheat Sheet, B) Glossary, C) Shareable Project README, D) Further Reading


1) Introduction & Outcomes

Symfony philosophy. Symfony is a set of reusable components and a full-stack framework that balances convention-over-configuration with explicit, readable config. You’ll learn to compose its pieces—Security, Doctrine, Twig, Validator, Mailer, Messenger, Serializer—into a production-grade app.

You’ll come away confident in:

  • Bootstrapping a Symfony 7.3 app with AssetMapper, UX/Stimulus, and Dockerized Postgres

  • Modeling a domain with Doctrine & writing migrations

  • Building secure auth (registration, login, email verification, password reset)

  • Designing voters for fine-grained authorization

  • Building CRUD with forms, validation, pagination & filters

  • Implementing safe uploads & protected file delivery

  • Dispatching async emails & reminders with Messenger (Doctrine transport) Symfony

  • Designing a minimal JSON API (Serializer groups, DTOs, Validation)

  • Applying HTTP & app-level caches

  • Writing PHPUnit tests (unit, functional, repository)

  • Deploying with Docker (Nginx + PHP-FPM + Postgres) and running migrations

What you learned (this section)

  • Why Symfony and what competencies you’ll gain

Sanity checks

php -v     # PHP 8.2+ expected

Common pitfalls & fixes

  • Using older Symfony: 6.4 LTS works too, but examples assume 7.3 behavior (AssetMapper). If using 6.4 LTS, AssetMapper still exists; avoid mixing with Encore unless you know the trade-offs. Symfony+1

Stretch goals: Swap DB to MySQL later, add Mercure/SSE for live updates
Windows notes: Prefer WSL2 for a Linux-like environment; otherwise use PowerShell equivalents below.


2) Environment Setup

Install / verify tools

macOS (Homebrew):

brew install php composer git docker
# Symfony CLI:
curl -sS https://get.symfony.com/cli/installer | bash
mv ~/.symfony*/bin/symfony /usr/local/bin/symfony

Linux (Debian/Ubuntu-like):

sudo apt-get update
sudo apt-get install -y php php-xml php-curl php-intl php-mbstring php-zip php-pgsql \
  git curl
# Composer:
php -r "copy('https://getcomposer.org/installer','composer-setup.php');"
php composer-setup.php --install-dir=/usr/local/bin --filename=composer
# Symfony CLI:
curl -sS https://get.symfony.com/cli/installer | bash
sudo mv ~/.symfony*/bin/symfony /usr/local/bin/symfony
# Docker:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER

Windows:

  • Install WSL2 + Ubuntu, then follow Linux steps inside WSL.

  • Or install PHP, Git, Docker Desktop, Composer, Symfony CLI (Windows installer on the official page). Symfony+1

Verify versions

php -v
composer -V
symfony -V
docker -v
git --version

DX tip: Symfony CLI adds quality-of-life features (local web server, project creation, Docker helper), and its GitHub repo lists latest releases & fixes if you’re curious. Symfony+2GitHub+2

Create a working folder & repo

mkdir -p ~/code/taskforge && cd ~/code/taskforge
git init
echo -e "/vendor/\n/var/\n/node_modules/\n.env.local\n/.php-cs-fixer.cache\n/.idea/\n/.vscode/" > .gitignore

What you learned

  • Installing/validating PHP, Composer, Symfony CLI, Docker, Git; repo hygiene

Sanity checks

symfony check:requirements

Common pitfalls & fixes

  • php-pgsql missing: apt-get install php-pgsql (Linux) or brew install php (macOS, includes pgsql).

  • Docker not found: Reboot or re-login after adding your user to the docker group.

  • Windows PATH issues: Use WSL2 to sidestep Windows PHP path quirks.

Stretch goals: Set up PHP CS Fixer & Psalm early
Windows notes: On PowerShell, replace echo -e with ni .gitignore -Type file; Add-Content .gitignore "...contents..."


3) Bootstrap the Symfony App

Create via Symfony CLI (webapp skeleton)

cd ~/code
symfony new taskforge --webapp --version=7.3
cd taskforge

This installs the full web app skeleton (Twig, Security, Doctrine, Mailer, etc.) wired via Flex recipes.

Folder tree (initial):

taskforge/
├─ bin/console
├─ config/
├─ public/
├─ src/
├─ templates/
├─ var/
├─ vendor/
├─ .env
└─ composer.json

Configure env vars for local dev

Create .env.local (git-ignored):

.env.local

###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=dev_change_me_please
###< symfony/framework-bundle ###

###> doctrine/doctrine-bundle ###
DATABASE_URL="postgresql://symfony:symfony@127.0.0.1:5432/taskforge?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###

###> symfony/mailer ###
MAILER_DSN=smtp://localhost:1025
###< symfony/mailer ###

###> symfony/messenger ###
# Use Doctrine transport backed by our main database:
MESSENGER_TRANSPORT_DSN=doctrine://default
###< symfony/messenger ###

Why Doctrine transport? It reuses your DB connection and stores messages in a DB table—simple and portable for development. You can swap to Redis/AMQP later. The docs show doctrine://default as a supported DSN. Symfony

Spin up PostgreSQL (and Mailpit) with Docker Compose

docker-compose.yml (at project root):

version: "3.9"

services:
  db:
    image: postgres:16-alpine
    container_name: taskforge_postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: taskforge
      POSTGRES_USER: symfony
      POSTGRES_PASSWORD: symfony
    volumes:
      - db_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    networks:
      - taskforge_net

  mailpit:
    image: axllent/mailpit:latest
    container_name: taskforge_mailpit
    restart: unless-stopped
    ports:
      - "1025:1025"   # SMTP
      - "8025:8025"   # Web UI
    networks:
      - taskforge_net

networks:
  taskforge_net:

volumes:
  db_data:

Start services:

docker compose up -d

Verify connection & run Doctrine migrations (empty initial)

bin/console doctrine:database:create
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate -n

At this stage, the schema is minimal (no entities yet), so the first diff will likely be empty; once we add entities, migrations will create tables.

What you learned

  • Creating a 7.3 webapp, local env vars, Dockerized Postgres + Mailpit, migrations workflow

Sanity checks

symfony serve -d
open http://127.0.0.1:8000   # macOS
# or
xdg-open http://127.0.0.1:8000  # Linux

Common pitfalls & fixes

  • Connection refused: Confirm DATABASE_URL host is 127.0.0.1 (Docker port mapping) and Postgres is up (docker compose ps).

  • Mailer not working: Check Mailpit UI at http://127.0.0.1:8025.

  • Migrations diff empty unexpectedly: Clear metadata cache (bin/console doctrine:cache:clear-metadata) and ensure your entities exist/are annotated.

Stretch goals: Add Redis service and change Messenger DSN to redis://localhost:6379/messages
Windows notes: Use symfony.exe serve -d in PowerShell; if ports collide, symfony proxy:domain:attach taskforge.test


4) Domain & Database Design

Entities & relationships

We’ll model:

  • User (id, email, password, roles, verified, createdAt)

  • Project (id, name, description, owner: User, members: ManyToMany User)

  • Task (id, project, title, description, status, priority, dueAt, assignee: User?, labels: ManyToMany Label, createdAt, updatedAt)

  • Label (id, name, color)

  • Attachment (id, task, originalName, path, mimeType, size, uploadedBy, createdAt)

  • Activity (id, task, user, type, data JSON, createdAt)

ERD (ASCII)

User (id) 1<--owns--* Project (id)
User (id) *<--members--> * Project

Project (id) 1<--has--* Task (id)
Task (id) *<--labels--> * Label (id)
Task (id) 1<--has--* Attachment (id)
Task (id) 1<--has--* Activity (id)

Task.assignee -> User (nullable)
Activity.user -> User
Attachment.uploadedBy -> User

Generate entities

We’ll use annotations/attributes. Run makers to create classes, then edit fields.

composer require symfony/maker-bundle --dev
bin/console make:user     # App\Entity\User; email as identifier
bin/console make:entity Project
bin/console make:entity Task
bin/console make:entity Label
bin/console make:entity Attachment
bin/console make:entity Activity

Now fill each file fully.

src/Entity/User.php

<?php

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[ORM\HasLifecycleCallbacks]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 180, unique: true)]
    #[Assert\NotBlank, Assert\Email]
    private string $email = '';

    /** @var string[] */
    #[ORM\Column(type: 'json')]
    private array $roles = ['ROLE_USER'];

    /** @var string The hashed password */
    #[ORM\Column]
    private string $password = '';

    #[ORM\Column(options: ['default' => false])]
    private bool $isVerified = false;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $createdAt;

    public function __construct()
    {
        $this->createdAt = new \DateTimeImmutable();
    }

    public function getId(): ?int { return $this->id; }

    public function getEmail(): string { return $this->email; }
    public function setEmail(string $email): self { $this->email = $email; return $this; }

    public function getUserIdentifier(): string { return $this->email; }

    public function getRoles(): array
    {
        $roles = $this->roles;
        if (!in_array('ROLE_USER', $roles, true)) { $roles[] = 'ROLE_USER'; }
        return array_unique($roles);
    }
    public function setRoles(array $roles): self { $this->roles = $roles; return $this; }

    public function getPassword(): string { return $this->password; }
    public function setPassword(string $password): self { $this->password = $password; return $this; }

    public function eraseCredentials(): void {}

    public function isVerified(): bool { return $this->isVerified; }
    public function setIsVerified(bool $verified): self { $this->isVerified = $verified; return $this; }

    public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
}

src/Entity/Project.php

<?php

namespace App\Entity;

use App\Repository\ProjectRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: ProjectRepository::class)]
#[ORM\Index(columns: ['name'], name: 'idx_project_name')]
class Project
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 160)]
    #[Assert\NotBlank]
    private string $name = '';

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $description = null;

    #[ORM\ManyToOne(inversedBy: 'ownedProjects')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private ?User $owner = null;

    #[ORM\ManyToMany(targetEntity: User::class)]
    #[ORM\JoinTable(name: 'project_members')]
    private Collection $members;

    #[ORM\OneToMany(mappedBy: 'project', targetEntity: Task::class, orphanRemoval: true)]
    private Collection $tasks;

    public function __construct()
    {
        $this->members = new ArrayCollection();
        $this->tasks = new ArrayCollection();
    }

    public function getId(): ?int { return $this->id; }
    public function getName(): string { return $this->name; }
    public function setName(string $name): self { $this->name = $name; return $this; }

    public function getDescription(): ?string { return $this->description; }
    public function setDescription(?string $description): self { $this->description = $description; return $this; }

    public function getOwner(): ?User { return $this->owner; }
    public function setOwner(User $owner): self { $this->owner = $owner; return $this; }

    /** @return Collection<int, User> */
    public function getMembers(): Collection { return $this->members; }
    public function addMember(User $user): self { if(!$this->members->contains($user)) $this->members->add($user); return $this; }
    public function removeMember(User $user): self { $this->members->removeElement($user); return $this; }

    /** @return Collection<int, Task> */
    public function getTasks(): Collection { return $this->tasks; }
}

src/Entity/Task.php

<?php

namespace App\Entity;

use App\Repository\TaskRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

enum TaskStatus: string { case TODO='todo'; case IN_PROGRESS='in_progress'; case DONE='done'; }

#[ORM\Entity(repositoryClass: TaskRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\Index(columns: ['status', 'priority', 'due_at'], name: 'idx_task_filters')]
class Task
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(inversedBy: 'tasks')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private ?Project $project = null;

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

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $description = null;

    #[ORM\Column(enumType: TaskStatus::class)]
    private TaskStatus $status = TaskStatus::TODO;

    #[ORM\Column(type: 'smallint', options: ['unsigned' => true])]
    #[Assert\Range(min: 0, max: 3)]
    private int $priority = 1; // 0=low,1=normal,2=high,3=urgent

    #[ORM\Column(name:'due_at', type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $dueAt = null;

    #[ORM\ManyToOne]
    private ?User $assignee = null;

    #[ORM\ManyToMany(targetEntity: Label::class)]
    #[ORM\JoinTable(name: 'task_labels')]
    private Collection $labels;

    #[ORM\OneToMany(mappedBy: 'task', targetEntity: Attachment::class, orphanRemoval: true)]
    private Collection $attachments;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $updatedAt;

    public function __construct()
    {
        $this->labels = new ArrayCollection();
        $this->attachments = new ArrayCollection();
        $this->createdAt = new \DateTimeImmutable();
        $this->updatedAt = new \DateTimeImmutable();
    }

    #[ORM\PreUpdate]
    public function onPreUpdate(): void { $this->updatedAt = new \DateTimeImmutable(); }

    // getters/setters omitted for brevity in comments—implement for all properties
    public function getId(): ?int { return $this->id; }
    public function getProject(): ?Project { return $this->project; }
    public function setProject(Project $project): self { $this->project = $project; return $this; }
    public function getTitle(): string { return $this->title; }
    public function setTitle(string $t): self { $this->title = $t; return $this; }
    public function getDescription(): ?string { return $this->description; }
    public function setDescription(?string $d): self { $this->description = $d; return $this; }
    public function getStatus(): TaskStatus { return $this->status; }
    public function setStatus(TaskStatus $s): self { $this->status = $s; return $this; }
    public function getPriority(): int { return $this->priority; }
    public function setPriority(int $p): self { $this->priority = $p; return $this; }
    public function getDueAt(): ?\DateTimeImmutable { return $this->dueAt; }
    public function setDueAt(?\DateTimeImmutable $d): self { $this->dueAt = $d; return $this; }
    public function getAssignee(): ?User { return $this->assignee; }
    public function setAssignee(?User $u): self { $this->assignee = $u; return $this; }
    public function getLabels(): Collection { return $this->labels; }
    public function addLabel(Label $l): self { if(!$this->labels->contains($l)) $this->labels->add($l); return $this; }
    public function removeLabel(Label $l): self { $this->labels->removeElement($l); return $this; }
    public function getAttachments(): Collection { return $this->attachments; }
    public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
    public function getUpdatedAt(): \DateTimeImmutable { return $this->updatedAt; }
}

src/Entity/Label.php

<?php

namespace App\Entity;

use App\Repository\LabelRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: LabelRepository::class)]
#[ORM\UniqueConstraint(columns: ['name'])]
class Label
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 80)]
    #[Assert\NotBlank]
    private string $name = '';

    #[ORM\Column(length: 7, options: ['fixed' => true])]
    #[Assert\Regex('/^#[0-9A-Fa-f]{6}$/')]
    private string $color = '#888888';

    public function getId(): ?int { return $this->id; }
    public function getName(): string { return $this->name; }
    public function setName(string $n): self { $this->name = $n; return $this; }
    public function getColor(): string { return $this->color; }
    public function setColor(string $c): self { $this->color = $c; return $this; }
}

src/Entity/Attachment.php

<?php

namespace App\Entity;

use App\Repository\AttachmentRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: AttachmentRepository::class)]
#[ORM\Index(columns: ['mime_type'], name: 'idx_attachment_mime')]
class Attachment
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column] private ?int $id=null;

    #[ORM\ManyToOne(inversedBy:'attachments')]
    #[ORM\JoinColumn(nullable:false, onDelete:'CASCADE')]
    private ?Task $task = null;

    #[ORM\Column(length:255)] private string $originalName='';
    #[ORM\Column(length:255)] private string $path=''; // relative path on disk
    #[ORM\Column(length:100, name:'mime_type')] private string $mimeType='';
    #[ORM\Column(type:'bigint')] private int $size=0;

    #[ORM\ManyToOne] private ?User $uploadedBy=null;

    #[ORM\Column(type:'datetime_immutable')] private \DateTimeImmutable $createdAt;

    public function __construct() { $this->createdAt = new \DateTimeImmutable(); }

    // getters/setters ...
    public function getId(): ?int { return $this->id; }
    public function getTask(): ?Task { return $this->task; }
    public function setTask(Task $t): self { $this->task = $t; return $this; }
    public function getOriginalName(): string { return $this->originalName; }
    public function setOriginalName(string $n): self { $this->originalName = $n; return $this; }
    public function getPath(): string { return $this->path; }
    public function setPath(string $p): self { $this->path = $p; return $this; }
    public function getMimeType(): string { return $this->mimeType; }
    public function setMimeType(string $m): self { $this->mimeType = $m; return $this; }
    public function getSize(): int { return $this->size; }
    public function setSize(int $s): self { $this->size = $s; return $this; }
    public function getUploadedBy(): ?User { return $this->uploadedBy; }
    public function setUploadedBy(?User $u): self { $this->uploadedBy = $u; return $this; }
    public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
}

src/Entity/Activity.php

<?php

namespace App\Entity;

use App\Repository\ActivityRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ActivityRepository::class)]
#[ORM\Index(columns: ['created_at'], name: 'idx_activity_created')]
class Activity
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column] private ?int $id=null;

    #[ORM\ManyToOne] #[ORM\JoinColumn(nullable:false, onDelete:'CASCADE')]
    private ?Task $task = null;

    #[ORM\ManyToOne] private ?User $user = null;

    #[ORM\Column(length: 80)] private string $type = ''; // e.g., created, updated, status_changed
    #[ORM\Column(type:'json', nullable:true)] private ?array $data=null;

    #[ORM\Column(name:'created_at', type:'datetime_immutable')]
    private \DateTimeImmutable $createdAt;

    public function __construct() { $this->createdAt = new \DateTimeImmutable(); }

    // getters...
    public function getId(): ?int { return $this->id; }
    public function getTask(): ?Task { return $this->task; }
    public function setTask(Task $t): self { $this->task = $t; return $this; }
    public function getUser(): ?User { return $this->user; }
    public function setUser(?User $u): self { $this->user = $u; return $this; }
    public function getType(): string { return $this->type; }
    public function setType(string $t): self { $this->type = $t; return $this; }
    public function getData(): ?array { return $this->data; }
    public function setData(?array $d): self { $this->data = $d; return $this; }
    public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
}

Migration round

bin/console make:migration
bin/console doctrine:migrations:migrate -n

Verify tables with psql:

docker exec -it taskforge_postgres psql -U symfony -d taskforge -c "\dt"

What you learned

  • Domain modeling in Doctrine, enums for status, indexes, ManyToMany joins, migrations

Sanity checks

bin/console doctrine:schema:validate

Common pitfalls & fixes

  • Enum mapping: Ensure #[ORM\Column(enumType: TaskStatus::class)] (not string)

  • Join cascade: Use onDelete:'CASCADE' carefully; it simplifies orphan cleanup.

  • Missing getters/setters: Make sure forms/serializer can access fields.

Stretch goals: Add Comment entity; add unique per-project task slugs
Windows notes: Use docker exec -it <container> bash via PowerShell


5) Authentication & Authorization

We’ll implement:

  • Registration + email verification

  • Login/logout (form login)

  • Password reset

  • Roles & Voters (ProjectVoter, TaskVoter) for owner/member access

Docs pointers: Security overview & form login; Mailer for emails. Symfony+2Symfony+2

Composer installs

composer require symfony/security-bundle
composer require symfony/mailer
composer require symfonycasts/verify-email-bundle
composer require symfonycasts/reset-password-bundle

Security config

config/packages/security.yaml

security:
  password_hashers:
    Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'

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

  firewalls:
    dev:
      pattern: ^/(_(profiler|wdt)|css|images|js)/
      security: false

    main:
      lazy: true
      provider: app_user_provider
      form_login:
        login_path: app_login
        check_path: app_login
        enable_csrf: true
        default_target_path: app_dashboard
      logout:
        path: app_logout
        target: app_login
      remember_me:
        secret: '%kernel.secret%'
        lifetime: 604800 # 7 days

  access_control:
    - { path: ^/login, roles: PUBLIC_ACCESS }
    - { path: ^/register, roles: PUBLIC_ACCESS }
    - { path: ^/verify, roles: PUBLIC_ACCESS }
    - { path: ^/reset-password, roles: PUBLIC_ACCESS }
    - { path: ^/api, roles: ROLE_USER }
    - { path: ^/, roles: ROLE_USER }

Registration controller & email verification

src/Controller/Auth/RegistrationController.php

<?php

namespace App\Controller\Auth;

use App\Entity\User;
use App\Form\RegistrationFormType;
use App\Security\AppAuthenticator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;

#[Route('/register')]
class RegistrationController extends AbstractController
{
    #[Route('', name: 'app_register', methods: ['GET','POST'])]
    public function register(
        Request $request,
        UserPasswordHasherInterface $passwordHasher,
        EntityManagerInterface $em,
        VerifyEmailHelperInterface $verifyHelper
    ): Response {
        $user = new User();
        $form = $this->createForm(RegistrationFormType::class, $user);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $user->setPassword($passwordHasher->hashPassword($user, $user->getPassword()));
            $em->persist($user);
            $em->flush();

            $signatureComponents = $verifyHelper->generateSignature(
                'app_verify_email', $user->getId(), $user->getEmail()
            );

            $email = (new TemplatedEmail())
                ->to($user->getEmail())
                ->subject('Verify your TaskForge email')
                ->htmlTemplate('emails/verify_email.html.twig')
                ->context(['signedUrl' => $signatureComponents->getSignedUrl()]);
            $this->container->get('mailer')->send($email);

            $this->addFlash('success', 'Registration successful! Check your email to verify.');
            return $this->redirectToRoute('app_login');
        }

        return $this->render('auth/register.html.twig', ['registrationForm' => $form->createView()]);
    }

    #[Route('/verify', name: 'app_verify_email')]
    public function verify(Request $request, VerifyEmailHelperInterface $verifyHelper, EntityManagerInterface $em): Response
    {
        $id = $request->query->get('id');
        $email = $request->query->get('email');
        $user = $em->getRepository(User::class)->find($id);
        $verifyHelper->validateEmailConfirmation($request->getUri(), $id, $email);
        $user->setIsVerified(true);
        $em->flush();
        $this->addFlash('success', 'Email verified! Please login.');
        return $this->redirectToRoute('app_login');
    }
}

src/Form/RegistrationFormType.php

<?php

namespace App\Form;

use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\{EmailType, PasswordType, RepeatedType, TextType};
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;

class RegistrationFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $b, array $options): void
    {
        $b->add('email', EmailType::class, [
              'constraints' => [new Assert\NotBlank(), new Assert\Email()],
        ])->add('password', RepeatedType::class, [
              'type' => PasswordType::class,
              'first_options' => ['label' => 'Password'],
              'second_options' => ['label' => 'Repeat Password'],
              'invalid_message' => 'Passwords must match.',
              'mapped' => true,
              'constraints' => [new Assert\NotBlank(), new Assert\Length(min:8)],
        ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => User::class]);
    }
}

templates/auth/register.html.twig

{% extends 'base.html.twig' %}
{% block title %}Register{% endblock %}
{% block body %}
  <h1>Create your account</h1>
  {{ form_start(registrationForm) }}
    {{ form_row(registrationForm.email) }}
    {{ form_row(registrationForm.password.first) }}
    {{ form_row(registrationForm.password.second) }}
    <button class="btn">Register</button>
  {{ form_end(registrationForm) }}
{% endblock %}

templates/emails/verify_email.html.twig

<p>Welcome to TaskForge!</p>
<p>Click to verify your email:</p>
<p><a href="{{ signedUrl }}">Verify my email</a></p>

Login & logout

src/Controller/Auth/SecurityController.php

<?php

namespace App\Controller\Auth;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\HttpFoundation\Response;

class SecurityController extends AbstractController
{
    #[Route('/login', name: 'app_login')]
    public function login(AuthenticationUtils $utils): Response
    {
        return $this->render('auth/login.html.twig', [
            'last_username' => $utils->getLastUsername(),
            'error' => $utils->getLastAuthenticationError(),
        ]);
    }

    #[Route('/logout', name: 'app_logout')]
    public function logout(): void { /* handled by Symfony */ }
}

templates/auth/login.html.twig

{% extends 'base.html.twig' %}
{% block title %}Login{% endblock %}
{% block body %}
<h1>Sign in</h1>
{% if error %}<div class="error">{{ error.messageKey|trans(error.messageData, 'security') }}</div>{% endif %}
<form method="post">
  <label>Email</label>
  <input type="email" name="_username" value="{{ last_username }}" required autofocus>
  <label>Password</label>
  <input type="password" name="_password" required>
  <button class="btn">Login</button>
</form>
{% endblock %}

Password reset (bundle boilerplate)

Run:

bin/console make:reset-password

Follow prompts; it scaffolds controller, form, email, and storage for tokens.

Roles & Voters

src/Security/Voter/ProjectVoter.php

<?php

namespace App\Security\Voter;

use App\Entity\Project;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class ProjectVoter extends Voter
{
    public const VIEW='PROJECT_VIEW';
    public const EDIT='PROJECT_EDIT';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, [self::VIEW, self::EDIT], true) && $subject instanceof Project;
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if (!$user instanceof User) return false;

        /** @var Project $project */
        $project = $subject;

        $isOwner = $project->getOwner()?->getId() === $user->getId();
        $isMember = $project->getMembers()->exists(fn($k,$m)=>$m->getId()===$user->getId());
        return match ($attribute) {
            self::VIEW => $isOwner || $isMember || in_array('ROLE_ADMIN', $user->getRoles(), true),
            self::EDIT => $isOwner || in_array('ROLE_ADMIN', $user->getRoles(), true),
        };
    }
}

Security callout: Symfony SecurityBundle centralizes auth & authorization, handling CSRF, sessions, and voters. Symfony

What you learned

  • Form login, registration, verification email, password reset, voters for authZ

Sanity checks

  • Visit /register, create a user, see verification mail in Mailpit (http://127.0.0.1:8025), click verify, then login at /login.

  • Try accessing a project you don’t own or belong to; expect 403.

Common pitfalls & fixes

  • Email not delivered: Check MAILER_DSN and Mailpit port; see Mailer docs. Symfony

  • Remember-me not working: Ensure cookie encryption secret set and browser not blocking third-party cookies.

  • Voter never called: Make sure you call $this->denyAccessUnlessGranted(ProjectVoter::VIEW, $project); in controllers.

Stretch goals: Add 2FA via Notifier; add OAuth login
Windows notes: If clicking verify opens WSL URL, copy link to Windows browser or use http://localhost:8025


6) CRUD for Projects & Tasks (Web UI)

Forms, controllers, views

bin/console make:controller DashboardController
bin/console make:crud Project
bin/console make:crud Task

Tweak the generated code.

src/Controller/DashboardController.php

<?php
namespace App\Controller;

use App\Repository\ProjectRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpFoundation\Response;

class DashboardController extends AbstractController
{
    #[Route('/', name: 'app_dashboard')]
    public function index(ProjectRepository $projects): Response
    {
        $user = $this->getUser();
        $myProjects = $projects->findBy(['owner' => $user], ['id' => 'DESC'], 5);
        return $this->render('dashboard/index.html.twig', ['projects' => $myProjects]);
    }
}

Filters & pagination (simple limit/offset approach):

src/Repository/TaskRepository.php (add a query method)

public function searchByFilters(int $projectId, array $filters, int $page=1, int $perPage=20): array
{
    $qb = $this->createQueryBuilder('t')
        ->andWhere('t.project = :pid')->setParameter('pid', $projectId);

    if (!empty($filters['status'])) $qb->andWhere('t.status = :status')->setParameter('status', $filters['status']);
    if (isset($filters['priority'])) $qb->andWhere('t.priority = :prio')->setParameter('prio', (int)$filters['priority']);
    if (!empty($filters['dueBefore'])) $qb->andWhere('t.dueAt <= :due')->setParameter('due', $filters['dueBefore']);

    $qb->orderBy('t.updatedAt','DESC')
       ->setMaxResults($perPage)->setFirstResult(($page-1)*$perPage);

    $data = $qb->getQuery()->getResult();

    $countQb = clone $qb; $countQb->resetDQLPart('orderBy')->select('COUNT(t.id)');
    $total = (int)$countQb->getQuery()->getSingleScalarResult();

    return ['items'=>$data, 'total'=>$total, 'page'=>$page, 'perPage'=>$perPage];
}

Stimulus micro-interaction: Inline status toggle.

assets/controllers/status_toggle_controller.js

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static values = { url: String, next: String }

  async toggle() {
    const res = await fetch(this.urlValue, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }});
    if (res.ok) {
      if (this.nextValue) window.location = this.nextValue; else window.location.reload();
    } else {
      alert('Failed to toggle status');
    }
  }
}

Register with AssetMapper/Stimulus:

assets/bootstrap.js (created by UX recipe—ensure Stimulus init present)

In a Twig template:

<button {{ stimulus_controller('status_toggle', { url: path('task_toggle_status', {id: task.id}), next: app.request.uri }) }}
        data-action="click->status_toggle#toggle">
  Toggle Status
</button>

UX note: Stimulus is part of Symfony UX and integrates with AssetMapper for zero-bundler DX. Symfony UX+1

What you learned

  • Using maker to scaffold CRUD, custom repository filters, simple pagination, Stimulus interactions

Sanity checks

  • Create a project & task, filter by status/priority, try the toggle button

Common pitfalls & fixes

  • JS not loading: Ensure AssetMapper is enabled and the {{ importmap() }} or mapped script tags exist in base.html.twig.

  • Pagination count wrong: Use a separate COUNT query (don’t count the paged result).

Stretch goals: Replace pagination with Pagerfanta; add sorting UI
Windows notes: None special—browser/dev tools the same


7) File Uploads & Attachments

We’ll store uploads outside public/ under var/uploads/attachments and serve via a controller + Nginx X-Accel-Redirect.

Config parameter & directory:

config/services.yaml (add)

parameters:
  attachments_dir: '%kernel.project_dir%/var/uploads/attachments'

Create directory:

mkdir -p var/uploads/attachments

File storage service

src/Service/FileStorage.php

<?php

namespace App\Service;

use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\File\UploadedFile;

class FileStorage
{
    public function __construct(private string $attachmentsDir) {}

    public function store(UploadedFile $file): array
    {
        $fs = new Filesystem();
        if (!$fs->exists($this->attachmentsDir)) $fs->mkdir($this->attachmentsDir);

        $safeName = bin2hex(random_bytes(8)).'__'.preg_replace('/[^A-Za-z0-9\.\-_]/','_', $file->getClientOriginalName());
        $target = $this->attachmentsDir.'/'.$safeName;
        $file->move($this->attachmentsDir, $safeName);

        return [
            'path' => $safeName,
            'original' => $file->getClientOriginalName(),
            'mime' => $file->getClientMimeType() ?? 'application/octet-stream',
            'size' => $file->getSize(),
        ];
    }
}

Service wiring (constructor arg):

config/services.yaml (add to services:)

services:
  App\Service\FileStorage:
    arguments:
      $attachmentsDir: '%attachments_dir%'

Attachment upload action

src/Controller/AttachmentController.php

<?php

namespace App\Controller;

use App\Entity\Attachment;
use App\Entity\Task;
use App\Security\Voter\ProjectVoter;
use App\Service\FileStorage;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\{BinaryFileResponse, Request, Response};
use Symfony\Component\Mime\MimeTypes;
use Symfony\Component\Routing\Attribute\Route;

class AttachmentController extends AbstractController
{
    #[Route('/tasks/{id}/attachments', name: 'task_upload_attachment', methods: ['POST'])]
    public function upload(Task $task, Request $request, FileStorage $storage, EntityManagerInterface $em): Response
    {
        $this->denyAccessUnlessGranted(ProjectVoter::EDIT, $task->getProject());

        $file = $request->files->get('file');
        if (!$file) return $this->json(['error'=>'No file'], 400);

        // Validate size & mime (basic)
        if ($file->getSize() > 10*1024*1024) return $this->json(['error'=>'Too large'], 413);

        $allowed = ['image/png','image/jpeg','application/pdf','text/plain','application/zip'];
        if (!in_array($file->getClientMimeType(), $allowed, true)) return $this->json(['error'=>'Disallowed type'], 415);

        $stored = $storage->store($file);
        $a = (new Attachment())
            ->setTask($task)
            ->setOriginalName($stored['original'])
            ->setPath($stored['path'])
            ->setMimeType($stored['mime'])
            ->setSize((int)$stored['size'])
            ->setUploadedBy($this->getUser());

        $em->persist($a); $em->flush();

        return $this->json(['ok'=>true, 'id'=>$a->getId()]);
    }

    #[Route('/attachments/{id}/download', name: 'attachment_download')]
    public function download(Attachment $a): Response
    {
        $project = $a->getTask()->getProject();
        $this->denyAccessUnlessGranted(ProjectVoter::VIEW, $project);

        // X-Accel for Nginx; fallback BinaryFileResponse for built-in server
        $path = $this->getParameter('attachments_dir').'/'.$a->getPath();
        if (isset($_SERVER['SERVER_SOFTWARE']) && str_contains($_SERVER['SERVER_SOFTWARE'], 'nginx')) {
            $response = new Response();
            $response->headers->set('Content-Type', $a->getMimeType());
            $response->headers->set('Content-Disposition', 'attachment; filename="'.$a->getOriginalName().'"');
            $response->headers->set('X-Accel-Redirect', '/protected/'.$a->getPath());
            return $response;
        }
        return new BinaryFileResponse($path);
    }
}

Nginx snippet (we’ll reuse in production):

location /protected/ {
    internal;
    alias /app/var/uploads/attachments/;
}

Security notes

  • Never trust client MIME types—validate & re-check via server-side detection (you can use MimeTypes::getDefault() if needed).

  • Store outside public/ to avoid direct execution.

  • For images exposed publicly, consider image sanitization.

  • Limit size, file types, and scan if possible.

What you learned

  • Safe file uploads, validation, storage abstraction, protected download via Nginx

Sanity checks

  • Upload a small PNG to a task; fetch /attachments/{id}/download and ensure it downloads.

Common pitfalls & fixes

  • Permissions: Ensure PHP can write to var/uploads/attachments.

  • Nginx alias wrong: Make sure alias path ends with trailing /.

  • Huge files: Tune client_max_body_size in Nginx.

Stretch goals: Integrate UX Dropzone; virus scanning; S3 storage with pre-signed URLs
Windows notes: Nginx is in Docker for prod; on Windows dev you’ll rely on BinaryFileResponse


8) Activity Logging

We’ll record key changes on Task updates using a Doctrine subscriber.

src/EventSubscriber/TaskActivitySubscriber.php

<?php

namespace App\EventSubscriber;

use App\Entity\Activity;
use App\Entity\Task;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Events;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Symfony\Bundle\SecurityBundle\Security;

class TaskActivitySubscriber implements EventSubscriber
{
    public function __construct(private Security $security) {}

    public function getSubscribedEvents(): array
    {
        return [Events::postPersist, Events::postUpdate];
    }

    public function postPersist(LifecycleEventArgs $args): void
    {
        $entity = $args->getObject();
        if (!$entity instanceof Task) return;

        $em = $args->getObjectManager();
        $a = (new Activity())
            ->setTask($entity)->setUser($this->security->getUser())
            ->setType('created')->setData(['title'=>$entity->getTitle()]);
        $em->persist($a); $em->flush();
    }

    public function postUpdate(LifecycleEventArgs $args): void
    {
        $entity = $args->getObject();
        if (!$entity instanceof Task) return;

        $em = $args->getObjectManager();
        $changeSet = $em->getUnitOfWork()->getEntityChangeSet($entity);
        $a = (new Activity())->setTask($entity)->setUser($this->security->getUser())
            ->setType('updated')->setData($changeSet);
        $em->persist($a); $em->flush();
    }
}

Register as a service (autoconfig picks subscribers by interface; if needed, tag it):

config/services.yaml (ensure autoconfigure: true)

Activity feed query: simply ActivityRepository findBy(['task'=>$task], ['createdAt'=>'DESC']).

What you learned

  • Doctrine subscribers for domain events

Sanity checks

  • Create/edit tasks and confirm entries in activity table

Common pitfalls & fixes

  • Recursive flush issues: Keep side-effects minimal; postUpdate uses changeSet after flush calculation

Stretch goals: Use a domain-event dispatcher pattern; render activity with Stimulus


9) Messaging & Background Jobs

We’ll use Messenger + Doctrine transport to send welcome emails and nightly due-soon reminders.

A Messenger transport is configured via DSN; examples include doctrine://default, redis://..., etc. Symfony

Install & configure

composer require symfony/messenger symfony/doctrine-messenger

config/packages/messenger.yaml

framework:
  messenger:
    transports:
      async: '%env(MESSENGER_TRANSPORT_DSN)%'
    routing:
      'App\Message\SendWelcomeEmail': async
      'App\Message\SendDueSoonReminders': async

Create transport schema:

bin/console messenger:setup-transports

Messages & handlers

src/Message/SendWelcomeEmail.php

<?php
namespace App\Message;

class SendWelcomeEmail
{
    public function __construct(public int $userId) {}
}

src/MessageHandler/SendWelcomeEmailHandler.php

<?php
namespace App\MessageHandler;

use App\Entity\User;
use App\Message\SendWelcomeEmail;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mailer\MailerInterface;

#[AsMessageHandler]
class SendWelcomeEmailHandler
{
    public function __construct(private EntityManagerInterface $em, private MailerInterface $mailer) {}

    public function __invoke(SendWelcomeEmail $msg): void
    {
        $user = $this->em->getRepository(User::class)->find($msg->userId);
        if (!$user) return;

        $email = (new TemplatedEmail())
            ->to($user->getEmail())->subject('Welcome to TaskForge!')
            ->htmlTemplate('emails/welcome.html.twig')
            ->context(['email'=>$user->getEmail()]);
        $this->mailer->send($email);
    }
}

Dispatch after registration:

// in RegistrationController after flush()
$this->dispatchMessage(new \App\Message\SendWelcomeEmail($user->getId()));

Due-soon reminders

src/Message/SendDueSoonReminders.php

<?php
namespace App\Message;

class SendDueSoonReminders {}

src/MessageHandler/SendDueSoonRemindersHandler.php

<?php
namespace App\MessageHandler;

use App\Entity\Task;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mailer\MailerInterface;

#[AsMessageHandler]
class SendDueSoonRemindersHandler
{
    public function __construct(private EntityManagerInterface $em, private MailerInterface $mailer) {}

    public function __invoke(\App\Message\SendDueSoonReminders $msg): void
    {
        $now = new \DateTimeImmutable();
        $soon = $now->modify('+1 day');

        $tasks = $this->em->getRepository(Task::class)->createQueryBuilder('t')
            ->andWhere('t.dueAt IS NOT NULL')
            ->andWhere('t.dueAt BETWEEN :now AND :soon')
            ->setParameter('now', $now)->setParameter('soon', $soon)
            ->getQuery()->getResult();

        foreach ($tasks as $t) {
            $assignee = $t->getAssignee();
            if (!$assignee) continue;
            $email = (new TemplatedEmail())
                ->to($assignee->getEmail())->subject('[TaskForge] Task due soon: '.$t->getTitle())
                ->htmlTemplate('emails/due_soon.html.twig')
                ->context(['task' => $t]);
            $this->mailer->send($email);
        }
    }
}

Nightly trigger: Create a console command and cron it.

src/Command/DispatchDueSoonRemindersCommand.php

<?php
namespace App\Command;

use App\Message\SendDueSoonReminders;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Messenger\MessageBusInterface;

#[AsCommand(name:'tasks:send-reminders')]
class DispatchDueSoonRemindersCommand extends Command
{
    public function __construct(private MessageBusInterface $bus) { parent::__construct(); }
    protected function execute(InputInterface $i, OutputInterface $o): int
    {
        $this->bus->dispatch(new SendDueSoonReminders());
        $o->writeln('Dispatched reminders job');
        return Command::SUCCESS;
    }
}

Run the worker:

bin/console messenger:consume async -vv

Docs: Mailer & Messenger guides for setup & transports. Symfony+1

What you learned

  • Defining messages, handlers, routing to async transport, using a worker, scheduling via cron/command

Sanity checks

  • Register a new user → check welcome email in Mailpit

  • Create a task due tomorrow with an assignee → run bin/console tasks:send-reminders then the worker → see email

Common pitfalls & fixes

  • “No transport supports DSN”: ensure symfony/doctrine-messenger installed for Doctrine transport.

  • Worker not picking jobs: run consumer; check messenger_messages table exists (messenger:setup-transports).

Stretch goals: Switch to Redis/AMQP transport; retry strategies & DLQ
Windows notes: Keep worker running in a separate PowerShell tab


10) Caching & Performance

HTTP caching (ETag/Last-Modified)

src/Controller/ProjectController.php (snippet in show action)

$resp = $this->render('project/show.html.twig', ['project'=>$project]);
$resp->setLastModified($project->getOwner()->getCreatedAt());
$resp->setEtag(md5($project->getId().$project->getName().$project->getTasks()->count()));
if ($resp->isNotModified($request)) { return $resp; }
return $resp;

App cache for expensive queries

src/Controller/DashboardController.php (snippet)

public function index(ProjectRepository $projects, \Symfony\Contracts\Cache\CacheInterface $cache): Response
{
    $user = $this->getUser();
    $key = 'dashboard.projects.user.'.$user->getId();
    $myProjects = $cache->get($key, function($i) use ($projects, $user) {
        $i->expiresAfter(300);
        return $projects->findBy(['owner'=>$user], ['id'=>'DESC'], 5);
    });
    return $this->render('dashboard/index.html.twig', ['projects'=>$myProjects]);
}

Profiler tip: Use Symfony Web Profiler Toolbar to find slow DB queries and N+1s.

What you learned

  • HTTP cache headers & server-side app cache

Sanity checks

  • Inspect response headers for ETag / Last-Modified; refresh to see 304 Not Modified

Common pitfalls & fixes

  • Wrong ETag invalidation: Include fields that change when content changes.

  • Cache stampede: Use per-user keys; apply TTLs.

Stretch goals: Add cache invalidation with tags; reverse-proxy caching (Varnish/Cloudflare)


11) Minimal JSON API

Endpoints:

  • GET /api/tasks — list with filters

  • POST /api/tasks — create (DTO + Validation)

  • PATCH /api/tasks/{id}/status — update status

Serializer & groups — expose fields selectively; Validation for DTOs. Symfony

Serializer groups

config/packages/serializer.yaml

framework:
  serializer:
    enabled: true

src/Entity/Task.php (add groups)

use Symfony\Component\Serializer\Annotation\Groups;

#[Groups(['task:read'])]
public function getId(): ?int { return $this->id; }
// annotate getters or properties:
#[ORM\Column(length:160)]
#[Groups(['task:read'])]
private string $title = '';
// similarly add to status, priority, dueAt, etc.

DTO for create

src/Dto/TaskInput.php

<?php
namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

class TaskInput
{
    #[Assert\NotBlank] public string $title='';
    public ?string $description=null;
    #[Assert\Choice(['todo','in_progress','done'])] public string $status='todo';
    #[Assert\Range(min:0,max:3)] public int $priority=1;
    public ?\DateTimeImmutable $dueAt=null;
    #[Assert\NotNull] public int $projectId;
    public ?int $assigneeId=null;
}

API controller

src/Controller/Api/TaskApiController.php

<?php

namespace App\Controller\Api;

use App\Dto\TaskInput;
use App\Entity\Task;
use App\Entity\TaskStatus;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\TaskRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\HttpFoundation\Response;

#[Route('/api/tasks')]
class TaskApiController extends AbstractController
{
    #[Route('', name: 'api_tasks_list', methods: ['GET'])]
    public function list(Request $request, TaskRepository $repo, SerializerInterface $serializer): JsonResponse
    {
        $projectId = (int)$request->query->get('project');
        $filters = [
            'status' => $request->query->get('status'),
            'priority' => $request->query->get('priority') !== null ? (int)$request->query->get('priority') : null,
            'dueBefore' => $request->query->get('dueBefore') ? new \DateTimeImmutable($request->query->get('dueBefore')) : null,
        ];
        $page = max(1, (int)$request->query->get('page', 1));
        $result = $repo->searchByFilters($projectId, $filters, $page);

        $json = $serializer->serialize($result['items'], 'json', ['groups'=>['task:read']]);
        return new JsonResponse(['items'=>json_decode($json, true), 'total'=>$result['total'], 'page'=>$result['page']]);
    }

    #[Route('', name: 'api_tasks_create', methods: ['POST'])]
    public function create(Request $request, SerializerInterface $serializer, ValidatorInterface $validator, EntityManagerInterface $em): JsonResponse
    {
        /** @var TaskInput $input */
        $input = $serializer->deserialize($request->getContent(), TaskInput::class, 'json');
        $errors = $validator->validate($input);
        if (count($errors) > 0) return $this->json(['errors' => (string)$errors], 422);

        $project = $em->getRepository(Project::class)->find($input->projectId);
        $this->denyAccessUnlessGranted('PROJECT_EDIT', $project);

        $task = (new Task())->setProject($project)->setTitle($input->title)
            ->setDescription($input->description)
            ->setStatus(TaskStatus::from($input->status))
            ->setPriority($input->priority)
            ->setDueAt($input->dueAt);

        if ($input->assigneeId) {
            $assignee = $em->getRepository(User::class)->find($input->assigneeId);
            $task->setAssignee($assignee);
        }

        $em->persist($task); $em->flush();
        return $this->json(['id'=>$task->getId()], Response::HTTP_CREATED);
    }

    #[Route('/{id}/status', name: 'api_tasks_update_status', methods: ['PATCH'])]
    public function updateStatus(Task $task, Request $request, EntityManagerInterface $em): JsonResponse
    {
        $this->denyAccessUnlessGranted('PROJECT_EDIT', $task->getProject());

        $data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
        $task->setStatus(TaskStatus::from($data['status']));
        $em->flush();
        return $this->json(['ok'=>true]);
    }
}

Auth choice (simplest secure): Keep session-based auth for same-origin API (no extra CORS/CSRF complexity). If calling cross-origin, enable CORS and use token-based auth instead.

What you learned

  • Adding read groups, DTO + Validation, API endpoints with proper status codes

Sanity checks

curl "http://127.0.0.1:8000/api/tasks?project=1&status=todo" -b cookiejar
curl -X POST http://127.0.0.1:8000/api/tasks \
  -H 'Content-Type: application/json' -d '{"title":"API Task","projectId":1}' -b cookiejar

Common pitfalls & fixes

  • Serialization not respecting groups: Use serialize(..., 'json', ['groups'=>...]) and annotate properties/getters.

  • CORS errors: Same-origin avoids this; otherwise configure NelmioCorsBundle or framework CORS.

Stretch goals: Use API Platform; add JWT (lexik/jwt-authentication-bundle)


12) Testing

Install testing tools (already present with webapp skeleton). Configure test DB in .env.test:

DATABASE_URL="postgresql://symfony:symfony@127.0.0.1:5432/taskforge_test?serverVersion=16&charset=utf8"

Run migrations for test:

bin/console doctrine:database:create --env=test
bin/console doctrine:migrations:migrate --env=test -n

Unit test

tests/Unit/TaskTest.php

<?php

namespace App\Tests\Unit;

use App\Entity\Task;
use App\Entity\TaskStatus;
use PHPUnit\Framework\TestCase;

class TaskTest extends TestCase
{
    public function testStatusTransitions(): void
    {
        $t = new Task();
        $this->assertSame(TaskStatus::TODO, $t->getStatus());
        $t->setStatus(TaskStatus::IN_PROGRESS);
        $this->assertSame(TaskStatus::IN_PROGRESS, $t->getStatus());
        $t->setStatus(TaskStatus::DONE);
        $this->assertSame(TaskStatus::DONE, $t->getStatus());
    }
}

Functional test (login + create task)

tests/Functional/TaskFlowTest.php

<?php

namespace App\Tests\Functional;

use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

class TaskFlowTest extends WebTestCase
{
    public function testLoginAndCreateTask(): void
    {
        $client = static::createClient();
        $em = static::getContainer()->get(EntityManagerInterface::class);
        $hasher = static::getContainer()->get(UserPasswordHasherInterface::class);

        $u = new User(); $u->setEmail('test@example.com');
        $u->setPassword($hasher->hashPassword($u, 'secret1234'));
        $u->setIsVerified(true);
        $em->persist($u); $em->flush();

        $crawler = $client->request('GET', '/login');
        $form = $crawler->selectButton('Login')->form([
            '_username' => 'test@example.com',
            '_password' => 'secret1234',
        ]);
        $client->submit($form);
        $this->assertResponseRedirects('/');

        // Further: create project + task with forms (omitted here for brevity)
    }
}

Testing tip: Use Foundry or Fixtures bundle to seed realistic data; run tests in CI (GitHub Actions).

What you learned

  • Unit & functional test wiring, test DB setup

Sanity checks

php bin/phpunit --colors=always

Common pitfalls & fixes

  • Migrations missing in test: Always migrate test DB separately.

  • Session issues in functional tests: Symfony test client handles sessions automatically.

Stretch goals: Panther for browser tests; Repository tests with SQLite in-memory


13) Production Readiness & Deployment

We’ll deploy with Docker: php-fpm + nginx + postgres.

Dockerfile (php-fpm) at project root:

FROM php:8.3-fpm-alpine

RUN apk add --no-cache git curl libpq-dev oniguruma-dev icu-dev zlib-dev \
 && docker-php-ext-install pdo pdo_pgsql intl opcache

# Opcache recommended settings
RUN { \
  echo 'opcache.enable=1'; \
  echo 'opcache.enable_cli=0'; \
  echo 'opcache.memory_consumption=128'; \
  echo 'opcache.max_accelerated_files=10000'; \
  echo 'opcache.validate_timestamps=0'; \
} > /usr/local/etc/php/conf.d/opcache.ini

WORKDIR /app
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY . /app

RUN composer install --no-dev --prefer-dist --no-progress --no-interaction \
 && php bin/console cache:warmup --env=prod

CMD ["php-fpm"]

nginx.conf (in docker/nginx/nginx.conf)

server {
    listen 80;
    server_name _;
    root /app/public;

    location / {
        try_files $uri /index.php$is_args$args;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass php:9000;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_read_timeout 300;
    }

    # Protected downloads
    location /protected/ {
        internal;
        alias /app/var/uploads/attachments/;
    }

    client_max_body_size 20M;
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
    add_header Referrer-Policy "strict-origin-when-cross-origin";
}

docker-compose.prod.yml

version: "3.9"
services:
  php:
    build: .
    restart: unless-stopped
    env_file: .env.prod
    volumes:
      - .:/app
    depends_on:
      - db
  nginx:
    image: nginx:1.27-alpine
    depends_on: [php]
    ports: ["80:80"]
    volumes:
      - .:/app
      - ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-taskforge}
      POSTGRES_USER: ${POSTGRES_USER:-symfony}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-symfony}
    volumes:
      - db_data:/var/lib/postgresql/data

volumes:
  db_data:

.env.prod (example)

APP_ENV=prod
APP_SECRET=change_on_server
DATABASE_URL="postgresql://symfony:***@db:5432/taskforge?serverVersion=16&charset=utf8"
MAILER_DSN=smtp://mail-relay:25
MESSENGER_TRANSPORT_DSN=doctrine://default
TRUSTED_PROXIES=127.0.0.1,REMOTE_ADDR
TRUSTED_HOSTS=example.com

Deployment steps:

docker compose -f docker-compose.prod.yml up -d --build
docker compose -f docker-compose.prod.yml exec php php bin/console doctrine:migrations:migrate -n --env=prod
docker compose -f docker-compose.prod.yml exec php php bin/console cache:warmup --env=prod

Security: Set TRUSTED_PROXIES/TRUSTED_HOSTS if behind a proxy; ensure APP_ENV=prod.
Backups: Nightly pg_dump cron from the DB host/container; store offsite.
Zero-downtime note: Build new containers, run migrations, then switch traffic (blue/green or rolling).

What you learned

  • Production Dockerfiles, opcache, env strategy, migrations on deploy

Sanity checks

  • curl -I http://server/ shows 200 OK; try login & basic flows

Common pitfalls & fixes

  • Permissions on var/: ensure PHP can write cache/logs/uploads.

  • Wrong DB host in prod: container name db from Compose.

Stretch goals: CI/CD pipeline (GitHub Actions), health checks, read-only replicas for reporting


14) Troubleshooting Guide

  • DB connection refused

    • Cause: Postgres container not ready / wrong host

    • Fix: docker compose ps, wait or DATABASE_URL host 127.0.0.1 (dev) or db (prod)

  • No transport supports the given Messenger DSN

    • Cause: Missing symfony/doctrine-messenger while using doctrine:// DSN

    • Fix: composer require symfony/doctrine-messenger; rerun messenger:setup-transports

  • Migration diffs not detected

    • Cause: Metadata cache or annotations not loaded

    • Fix: doctrine:migrations:diff after clearing caches, verify entity namespaces

  • File upload 413

    • Cause: Nginx client_max_body_size too small

    • Fix: Increase in nginx.conf

  • CSRF errors on forms

    • Cause: Missing {{ form_rest() }} or tokens; or wrong firewall

    • Fix: Ensure enable_csrf: true in form_login and proper Twig form rendering

  • CORS errors (API)

    • Cause: Cross-origin calls without CORS

    • Fix: Same-origin session, or add CORS config/token auth

  • Mailer failures

    • Cause: DSN incorrect / relay blocked

    • Fix: Use Mailpit in dev, check ports and DSN; follow Mailer docs. Symfony

  • Worker idle

    • Cause: Not running

    • Fix: bin/console messenger:consume async -vv

Be explicit with error messages; search them in Symfony docs/blog/issues if stuck.


15) Wrap-Up, Next Steps & Resources

You built TaskForge with a complete CRUD domain, secure auth, voters, file uploads, activity logs, a minimal API, background jobs, caching, tests, and production Docker deployment. This mirrors real-world workflows and introduces the most important Symfony pillars.

Next features to explore

  • Teams & invitations

  • Notifications hub (web + email)

  • Live updates with Mercure or SSE (stretch)

  • Full-blown API Platform integration (OpenAPI, pagination, filters)

  • Advanced authorization (scopes, per-field voters)

Learning roadmap

  • Deep-dive each component’s docs (Security, Validator, Messenger, Serializer, etc.)

  • Explore Symfony’s release cadence & upgrade strategy (minor every 6 months). endoflife.date


Appendix A — Cheat Sheet

CLI Essentials

symfony new taskforge --webapp --version=7.3
symfony serve -d
bin/console make:entity
bin/console make:migration && bin/console doctrine:migrations:migrate
bin/console make:crud Project
bin/console make:controller
bin/console messenger:setup-transports
bin/console messenger:consume async -vv
bin/console tasks:send-reminders
php bin/phpunit

Directory Map (high-level)

/config          # app config (security, doctrine, messenger, serializer)
/public          # web root (index.php)
/src
  /Controller
  /Entity
  /Message + /MessageHandler
  /Repository
  /Security/Voter
  /Service
  /EventSubscriber
/templates       # Twig
/assets          # JS/CSS (AssetMapper)
/var             # cache, logs, uploads(not in public)
/docker          # prod nginx config

Common Config Keys

  • security.yaml: password_hashers, providers, firewalls.form_login, access_control

  • doctrine.yaml: dbal.url, orm.mappings, orm.dql

  • messenger.yaml: transports.async, routing

  • framework.yaml: cache, serializer.enabled, csrf_protection

  • services.yaml: constructor args, autowire/autoconfigure, parameters

  • serializer groups via @Groups annotations


Appendix B — Glossary (Alphabetized)

  • AssetMapper: Symfony’s zero-bundler asset pipeline to serve modern JS/CSS directly. Symfony

  • Autowiring: Automatically injecting services into constructors by type-hint.

  • Bundles: Feature packs that register services/config (e.g., SecurityBundle).

  • Controller: Class method that handles an HTTP request and returns a Response.

  • DTO (Data Transfer Object): Shape of data for IO; maps to entities.

  • Entity: Doctrine-mapped PHP class persisted to the database.

  • Env Vars: Values from .env/server used by config (e.g., DATABASE_URL).

  • Mailer: Symfony component to send emails. Symfony

  • Messenger: Message bus + transports for async work/queues. Symfony

  • Serializer: Transforms objects ↔ JSON/XML; selective exposure via groups. Symfony

  • Service Container: Manages service creation and dependency injection.

  • Stimulus: Lightweight JS controllers integrated with Twig via Symfony UX. Symfony UX

  • Twig: Symfony’s templating engine.

  • Validator: Constraint-based input validation.

  • Voter: Authorization logic object evaluating permissions.

  • Web Profiler: Toolbar & panels for debugging and performance.


Common Commands

bin/console messenger:setup-transports
bin/console messenger:consume async -vv
bin/console tasks:send-reminders
php bin/phpunit

Production (Docker)

docker compose -f docker-compose.prod.yml up -d --build
docker compose -f docker-compose.prod.yml exec php php bin/console doctrine:migrations:migrate -n --env=prod

Environment

  • .env.local for dev overrides (DB, MAILER_DSN, TRANSPORT_DSN)

  • APP_ENV=prod for production; set TRUSTED_PROXIES/HOSTS as needed

Security Notes

  • Attachments stored outside public/, served via controller + X-Accel-Redirect

  • CSRF enabled for forms; voters guard project/task access

  • Use strong APP_SECRET in production

License

MIT (or choose your license)

taskforge/
├─ assets/
│ └─ controllers/status_toggle_controller.js
├─ bin/console
├─ config/
│ ├─ packages/{security.yaml,doctrine.yaml,messenger.yaml,serializer.yaml,framework.yaml}
│ └─ services.yaml
├─ docker/nginx/nginx.conf
├─ docker-compose.yml
├─ docker-compose.prod.yml
├─ public/index.php
├─ src/
│ ├─ Command/DispatchDueSoonRemindersCommand.php
│ ├─ Controller/{Auth,Api}/...
│ ├─ Dto/TaskInput.php
│ ├─ Entity/{User,Project,Task,Label,Attachment,Activity}.php
│ ├─ EventSubscriber/TaskActivitySubscriber.php
│ ├─ Message/{SendWelcomeEmail,SendDueSoonReminders}.php
│ ├─ MessageHandler/{SendWelcomeEmailHandler,SendDueSoonRemindersHandler}.php
│ ├─ Repository/{...}Repository.php
│ ├─ Security/Voter/ProjectVoter.php
│ └─ Service/FileStorage.php
├─ templates/{auth,emails,project,task,dashboard}/...
├─ tests/{Unit,Functional}/...
├─ var/ (cache, logs, uploads)
├─ vendor/
├─ .env .env.local .env.test .gitignore
├─ composer.json composer.lock
├─ Dockerfile
└─ README.md