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

Search for a command to run...

No comments yet. Be the first to comment.
There's a question that most people feel but almost nobody says out loud. Not because it's complicated. Because saying it threatens everything built on top of not saying it. The question is simple. Wh

Public discourse around personal finance in India increasingly relies on lifestyle narratives, absolute numbers, and loosely imported benchmarks. Concepts such as middle class, financial security, high income, or wealthy are often used without refere...

By Ahmad W Khan (interactive at https://ahmadwkhan.com/india-work-landscape) Summary (TL;DR): India’s labour market is vast, diversified, and uneven. Government roles remain tenure‑rich but hard to enter; healthcare and licensed professional practice...

Money, unlike geography, is invisible.We know where a country starts and ends on a map. But wealth and income? They’re like air currents, everywhere, yet hard to see. Percentiles help make them visible: where do you sit compared to your neighbors, yo...

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
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
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
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
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
php -v # PHP 8.2+ expected
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.
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
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
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
symfony check:requirements
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..."
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
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
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
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.
symfony serve -d
open http://127.0.0.1:8000 # macOS
# or
xdg-open http://127.0.0.1:8000 # Linux
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
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
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; }
}
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"
bin/console doctrine:schema:validate
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
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 require symfony/security-bundle
composer require symfony/mailer
composer require symfonycasts/verify-email-bundle
composer require symfonycasts/reset-password-bundle
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 }
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>
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 %}
Run:
bin/console make:reset-password
Follow prompts; it scaffolds controller, form, email, and storage for tokens.
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
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.
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
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
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
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.
/attachments/{id}/download and ensure it downloads.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
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']).
activity tableStretch goals: Use a domain-event dispatcher pattern; render activity with Stimulus
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
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
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
async transport, using a worker, scheduling via cron/commandRegister 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
“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
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;
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.
ETag / Last-Modified; refresh to see 304 Not ModifiedWrong 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)
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
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.
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;
}
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.
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
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)
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
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());
}
}
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).
php bin/phpunit --colors=always
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
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).
curl -I http://server/ shows 200 OK; try login & basic flowsPermissions 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
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.
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
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
/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
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
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.
bin/console messenger:setup-transports
bin/console messenger:consume async -vv
bin/console tasks:send-reminders
php bin/phpunit
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
.env.local for dev overrides (DB, MAILER_DSN, TRANSPORT_DSN)
APP_ENV=prod for production; set TRUSTED_PROXIES/HOSTS as needed
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
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