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 votersDomain: 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
Introduction & Outcomes
Environment Setup
Bootstrap the Symfony App
Domain & Database Design
Authentication & Authorization
CRUD for Projects & Tasks (Web UI)
File Uploads & Attachments
Activity Logging
Messaging & Background Jobs
Caching & Performance
Minimal JSON API
Testing
Production Readiness & Deployment
Troubleshooting Guide
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) orbrew install php(macOS, includes pgsql).Docker not found: Reboot or re-login after adding your user to the
dockergroup.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://defaultas 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_URLhost is127.0.0.1(Docker port mapping) and Postgres isup(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_DSNand Mailpit port; see Mailer docs. SymfonyRemember-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 inbase.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}/downloadand ensure it downloads.
Common pitfalls & fixes
Permissions: Ensure PHP can write to
var/uploads/attachments.Nginx alias wrong: Make sure
aliaspath ends with trailing/.Huge files: Tune
client_max_body_sizein 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
activitytable
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
asynctransport, 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-remindersthen the worker → see email
Common pitfalls & fixes
“No transport supports DSN”: ensure
symfony/doctrine-messengerinstalled for Doctrine transport.Worker not picking jobs: run consumer; check
messenger_messagestable 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 see304 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 filtersPOST /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_HOSTSif behind a proxy; ensureAPP_ENV=prod.
Backups: Nightlypg_dumpcron 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/shows200 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
dbfrom 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 orDATABASE_URLhost127.0.0.1(dev) ordb(prod)
No transport supports the given Messenger DSNCause: Missing
symfony/doctrine-messengerwhile usingdoctrine://DSNFix:
composer require symfony/doctrine-messenger; rerunmessenger:setup-transports
Migration diffs not detected
Cause: Metadata cache or annotations not loaded
Fix:
doctrine:migrations:diffafter clearing caches, verify entity namespaces
File upload 413
Cause: Nginx
client_max_body_sizetoo smallFix: Increase in
nginx.conf
CSRF errors on forms
Cause: Missing
{{ form_rest() }}or tokens; or wrong firewallFix: Ensure
enable_csrf: trueinform_loginand 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_controldoctrine.yaml:dbal.url,orm.mappings,orm.dqlmessenger.yaml:transports.async,routingframework.yaml:cache,serializer.enabled,csrf_protectionservices.yaml: constructor args, autowire/autoconfigure, parametersserializergroups via@Groupsannotations
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.localfor dev overrides (DB, MAILER_DSN, TRANSPORT_DSN)APP_ENV=prodfor production; setTRUSTED_PROXIES/HOSTSas needed
Security Notes
Attachments stored outside
public/, served via controller +X-Accel-RedirectCSRF enabled for forms; voters guard project/task access
Use strong
APP_SECRETin 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




