# 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](https://symfony.com/releases?utm_source=chatgpt.com)  
**Frontend tooling:** Twig + **Symfony UX/Stimulus** using **AssetMapper** (default in modern Symfony). [Symfony+1](https://symfony.com/doc/current/frontend/asset_mapper.html?utm_source=chatgpt.com)  
**Reading Time:** ~2.5–3.5 hours, hands-on

---

## What you’ll build

**TaskForge** - a pragmatic project & task manager with:

* **Auth:** registration, login, email verification, password reset; roles (`ROLE_USER`, `ROLE_ADMIN`) protected by **voters**
    
* **Domain:** Projects & Tasks (+ labels, attachments, activity log)
    
* **UI:** Twig + Stimulus micro-interactions
    
* **API:** Minimal JSON API for Tasks (list/create/update) with **Serializer groups**, **DTOs**, **Validation**
    
* **Background jobs:** welcome email + due-soon reminders using **Messenger** (Doctrine transport via DB) [Symfony](https://symfony.com/doc/current/messenger.html?utm_source=chatgpt.com)
    
* **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](https://symfony.com/releases?utm_source=chatgpt.com)
    
* **Security:** Authentication & authorization (SecurityBundle) [Symfony](https://symfony.com/doc/current/security.html?utm_source=chatgpt.com)
    
* **Mailer:** Sending email (Mailer & Mime) [Symfony](https://symfony.com/doc/current/mailer.html?utm_source=chatgpt.com)
    
* **Messenger:** Async messages & transports (Doctrine DSN, Redis, AMQP) [Symfony](https://symfony.com/doc/current/messenger.html?utm_source=chatgpt.com)
    
* **Serializer:** Using serializer & groups for APIs [Symfony](https://symfony.com/doc/current/serializer.html?utm_source=chatgpt.com)
    
* **UX/Stimulus & AssetMapper:** official pages [Symfony UX+1](https://ux.symfony.com/documentation?utm_source=chatgpt.com)
    

> **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](https://symfony.com/releases/7.2?utm_source=chatgpt.com)

---

## Table of Contents

1. Introduction & Outcomes
    
2. Environment Setup
    
3. Bootstrap the Symfony App
    
4. Domain & Database Design
    
5. Authentication & Authorization
    
6. CRUD for Projects & Tasks (Web UI)
    
7. File Uploads & Attachments
    
8. Activity Logging
    
9. Messaging & Background Jobs
    
10. Caching & Performance
    
11. Minimal JSON API
    
12. Testing
    
13. Production Readiness & Deployment
    
14. Troubleshooting Guide
    
15. Wrap-Up, Next Steps & Resources  
    **Appendices:** A) Cheat Sheet, B) Glossary, C) Shareable Project README, D) Further Reading
    

---

## 1) Introduction & Outcomes

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

**You’ll come away confident in:**

* Bootstrapping a Symfony 7.3 app with AssetMapper, UX/Stimulus, and Dockerized Postgres
    
* Modeling a domain with Doctrine & writing migrations
    
* Building secure auth (registration, login, email verification, password reset)
    
* Designing voters for fine-grained authorization
    
* Building CRUD with forms, validation, pagination & filters
    
* Implementing safe uploads & protected file delivery
    
* Dispatching async emails & reminders with Messenger (Doctrine transport) [Symfony](https://symfony.com/doc/current/messenger.html?utm_source=chatgpt.com)
    
* 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

```xml
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](https://symfony.com/doc/current/frontend/asset_mapper.html?utm_source=chatgpt.com)
    

**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):**

```xml
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):**

```xml
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](https://symfony.com/download?utm_source=chatgpt.com)
    

### Verify versions

```xml
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](https://symfony.com/doc/current/setup/symfony_cli.html?utm_source=chatgpt.com)

### Create a working folder & repo

```xml
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

```xml
symfony check:requirements
```

#### Common pitfalls & fixes

* **php-pgsql missing:** `apt-get install php-pgsql` (Linux) or `brew install php` (macOS, includes pgsql).
    
* **Docker not found:** Reboot or re-login after adding your user to the `docker` group.
    
* **Windows PATH issues:** Use WSL2 to sidestep Windows PHP path quirks.
    

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

---

## 3) Bootstrap the Symfony App

### Create via Symfony CLI (webapp skeleton)

```xml
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):**

```xml
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`

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

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

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

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

> **Why Doctrine transport?** It reuses your DB connection and stores messages in a DB table—simple and portable for development. You can swap to Redis/AMQP later. The docs show `doctrine://default` as a supported DSN. [Symfony](https://symfony.com/doc/current/messenger.html?utm_source=chatgpt.com)

### Spin up PostgreSQL (and Mailpit) with Docker Compose

`docker-compose.yml` (at project root):

```dockerfile
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:

```xml
docker compose up -d
```

### Verify connection & run Doctrine migrations (empty initial)

```xml
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

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

#### Common pitfalls & fixes

* **Connection refused:** Confirm `DATABASE_URL` host is `127.0.0.1` (Docker port mapping) and Postgres is `up` (`docker compose ps`).
    
* **Mailer not working:** Check Mailpit UI at `http://127.0.0.1:8025`.
    
* **Migrations diff empty unexpectedly:** Clear metadata cache (`bin/console doctrine:cache:clear-metadata`) and ensure your entities exist/are annotated.
    

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

---

## 4) Domain & Database Design

### Entities & relationships

We’ll model:

* **User** (id, email, password, roles, verified, createdAt)
    
* **Project** (id, name, description, owner: User, members: ManyToMany User)
    
* **Task** (id, project, title, description, status, priority, dueAt, assignee: User?, labels: ManyToMany Label, createdAt, updatedAt)
    
* **Label** (id, name, color)
    
* **Attachment** (id, task, originalName, path, mimeType, size, uploadedBy, createdAt)
    
* **Activity** (id, task, user, type, data JSON, createdAt)
    

**ERD (ASCII)**

```xml
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.

```xml
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
<?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
<?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
<?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
<?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
<?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
<?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

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

**Verify tables with psql:**

```xml
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

```xml
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](https://symfony.com/doc/current/security.html?utm_source=chatgpt.com)

### Composer installs

```xml
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`

```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
<?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
<?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`

```xml
{% 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`

```xml
<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
<?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`

```xml
{% 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:

```xml
bin/console make:reset-password
```

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

### Roles & Voters

`src/Security/Voter/ProjectVoter.php`

```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](https://symfony.com/doc/current/security.html?utm_source=chatgpt.com)

#### What you learned

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

#### Sanity checks

* Visit `/register`, create a user, see verification mail in Mailpit (`http://127.0.0.1:8025`), click verify, then login at `/login`.
    
* Try accessing a project you don’t own or belong to; expect **403**.
    

#### Common pitfalls & fixes

* **Email not delivered:** Check `MAILER_DSN` and Mailpit port; see Mailer docs. [Symfony](https://symfony.com/doc/current/mailer.html?utm_source=chatgpt.com)
    
* **Remember-me not working:** Ensure cookie encryption secret set and browser not blocking third-party cookies.
    
* **Voter never called:** Make sure you call `$this->denyAccessUnlessGranted(ProjectVoter::VIEW, $project);` in controllers.
    

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

---

## 6) CRUD for Projects & Tasks (Web UI)

### Forms, controllers, views

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

Tweak the generated code.

`src/Controller/DashboardController.php`

```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)

```php
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`

```javascript
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:

```xml
<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](https://ux.symfony.com/stimulus?utm_source=chatgpt.com)

#### What you learned

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

#### Sanity checks

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

#### Common pitfalls & fixes

* **JS not loading:** Ensure AssetMapper is enabled and the `{{ importmap() }}` or mapped script tags exist in `base.html.twig`.
    
* **Pagination count wrong:** Use a separate COUNT query (don’t count the paged result).
    

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

---

## 7) File Uploads & Attachments

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

**Config parameter & directory:**

`config/services.yaml` (add)

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

Create directory:

```xml
mkdir -p var/uploads/attachments
```

**File storage service**

`src/Service/FileStorage.php`

```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:`)

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

**Attachment upload action**

`src/Controller/AttachmentController.php`

```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):

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

> **Security notes**
> 
> * Never trust client MIME types—validate & re-check via server-side detection (you can use `MimeTypes::getDefault()` if needed).
>     
> * Store outside `public/` to avoid direct execution.
>     
> * For images exposed publicly, consider image sanitization.
>     
> * Limit size, file types, and scan if possible.
>     

#### What you learned

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

#### Sanity checks

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

#### Common pitfalls & fixes

* **Permissions:** Ensure PHP can write to `var/uploads/attachments`.
    
* **Nginx alias wrong:** Make sure `alias` path ends with trailing `/`.
    
* **Huge files:** Tune `client_max_body_size` in Nginx.
    

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

---

## 8) Activity Logging

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

`src/EventSubscriber/TaskActivitySubscriber.php`

```php
<?php

namespace App\EventSubscriber;

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

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

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

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

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

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

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

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

`config/services.yaml` (ensure `autoconfigure: true`)

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

#### What you learned

* Doctrine subscribers for domain events
    

#### Sanity checks

* Create/edit tasks and confirm entries in `activity` table
    

#### Common pitfalls & fixes

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

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

---

## 9) Messaging & Background Jobs

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

> A Messenger transport is configured via DSN; examples include `doctrine://default`, `redis://...`, etc. [Symfony](https://symfony.com/doc/current/messenger.html?utm_source=chatgpt.com)

### Install & configure

```xml
composer require symfony/messenger symfony/doctrine-messenger
```

`config/packages/messenger.yaml`

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

Create transport schema:

```xml
bin/console messenger:setup-transports
```

### Messages & handlers

`src/Message/SendWelcomeEmail.php`

```php
<?php
namespace App\Message;

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

`src/MessageHandler/SendWelcomeEmailHandler.php`

```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:

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

**Due-soon reminders**

`src/Message/SendDueSoonReminders.php`

```php
<?php
namespace App\Message;

class SendDueSoonReminders {}
```

`src/MessageHandler/SendDueSoonRemindersHandler.php`

```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
<?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:

```xml
bin/console messenger:consume async -vv
```

> **Docs:** Mailer & Messenger guides for setup & transports. [Symfony+1](https://symfony.com/doc/current/mailer.html?utm_source=chatgpt.com)

#### What you learned

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

#### Sanity checks

* Register a new user → check welcome email in Mailpit
    
* Create a task due tomorrow with an assignee → run `bin/console tasks:send-reminders` then the worker → see email
    

#### Common pitfalls & fixes

* **“No transport supports DSN”**: ensure `symfony/doctrine-messenger` installed for Doctrine transport.
    
* **Worker not picking jobs**: run consumer; check `messenger_messages` table exists (`messenger:setup-transports`).
    

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

---

## 10) Caching & Performance

### HTTP caching (ETag/Last-Modified)

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

```php
$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)

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

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

#### What you learned

* HTTP cache headers & server-side app cache
    

#### Sanity checks

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

#### Common pitfalls & fixes

* **Wrong ETag invalidation:** Include fields that change when content changes.
    
* **Cache stampede:** Use per-user keys; apply TTLs.
    

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

---

## 11) Minimal JSON API

Endpoints:

* `GET /api/tasks` — list with filters
    
* `POST /api/tasks` — create (DTO + Validation)
    
* `PATCH /api/tasks/{id}/status` — update status
    

> **Serializer & groups** — expose fields selectively; **Validation** for DTOs. [Symfony](https://symfony.com/doc/current/serializer.html?utm_source=chatgpt.com)

### Serializer groups

`config/packages/serializer.yaml`

```xml
framework:
  serializer:
    enabled: true
```

`src/Entity/Task.php` (add groups)

```php
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
<?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
<?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

```xml
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`:

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

Run migrations for test:

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

### Unit test

`tests/Unit/TaskTest.php`

```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
<?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

```xml
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:

```dockerfile
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`)

```nginx
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`

```dockerfile
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)

```xml
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:**

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

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

#### What you learned

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

#### Sanity checks

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

#### Common pitfalls & fixes

* **Permissions on** `var/`: ensure PHP can write cache/logs/uploads.
    
* **Wrong DB host in prod:** container name `db` from Compose.
    

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

---

## 14) Troubleshooting Guide

* **DB connection refused**
    
    * Cause: Postgres container not ready / wrong host
        
    * Fix: `docker compose ps`, wait or `DATABASE_URL` host `127.0.0.1` (dev) or `db` (prod)
        
* `No transport supports the given Messenger DSN`
    
    * Cause: Missing `symfony/doctrine-messenger` while using `doctrine://` DSN
        
    * Fix: `composer require symfony/doctrine-messenger`; rerun `messenger:setup-transports`
        
* **Migration diffs not detected**
    
    * Cause: Metadata cache or annotations not loaded
        
    * Fix: `doctrine:migrations:diff` after clearing caches, verify entity namespaces
        
* **File upload 413**
    
    * Cause: Nginx `client_max_body_size` too small
        
    * Fix: Increase in `nginx.conf`
        
* **CSRF errors on forms**
    
    * Cause: Missing `{{ form_rest() }}` or tokens; or wrong firewall
        
    * Fix: Ensure `enable_csrf: true` in `form_login` and proper Twig form rendering
        
* **CORS errors (API)**
    
    * Cause: Cross-origin calls without CORS
        
    * Fix: Same-origin session, or add CORS config/token auth
        
* **Mailer failures**
    
    * Cause: DSN incorrect / relay blocked
        
    * Fix: Use Mailpit in dev, check ports and DSN; follow Mailer docs. [Symfony](https://symfony.com/doc/current/mailer.html?utm_source=chatgpt.com)
        
* **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](https://endoflife.date/symfony?utm_source=chatgpt.com)
    

---

# Appendix A — Cheat Sheet

### CLI Essentials

```xml
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)

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

### Common Config Keys

* `security.yaml`: `password_hashers`, `providers`, `firewalls.form_login`, `access_control`
    
* `doctrine.yaml`: `dbal.url`, `orm.mappings`, `orm.dql`
    
* `messenger.yaml`: `transports.async`, `routing`
    
* `framework.yaml`: `cache`, `serializer.enabled`, `csrf_protection`
    
* `services.yaml`: constructor args, autowire/autoconfigure, parameters
    
* `serializer` groups via `@Groups` annotations
    

---

# Appendix B — Glossary (Alphabetized)

* **AssetMapper:** Symfony’s zero-bundler asset pipeline to serve modern JS/CSS directly. [Symfony](https://symfony.com/doc/current/frontend/asset_mapper.html?utm_source=chatgpt.com)
    
* **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](https://symfony.com/doc/current/mailer.html?utm_source=chatgpt.com)
    
* **Messenger:** Message bus + transports for async work/queues. [Symfony](https://symfony.com/doc/current/messenger.html?utm_source=chatgpt.com)
    
* **Serializer:** Transforms objects ↔ JSON/XML; selective exposure via groups. [Symfony](https://symfony.com/doc/current/serializer.html?utm_source=chatgpt.com)
    
* **Service Container:** Manages service creation and dependency injection.
    
* **Stimulus:** Lightweight JS controllers integrated with Twig via Symfony UX. [Symfony UX](https://ux.symfony.com/stimulus?utm_source=chatgpt.com)
    
* **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

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

## Production (Docker)

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

## Environment

* `.env.local` for dev overrides (DB, MAILER\_DSN, TRANSPORT\_DSN)
    
* `APP_ENV=prod` for production; set `TRUSTED_PROXIES/HOSTS` as needed
    

## Security Notes

* Attachments stored outside `public/`, served via controller + `X-Accel-Redirect`
    
* CSRF enabled for forms; voters guard project/task access
    
* Use strong `APP_SECRET` in production
    

## License

MIT (or choose your license)

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

---
