First Module Tutorial
Complete step-by-step guide to build your first hexagonal module from scratch.
What We’ll Build
A Blog Post module with:
- CRUD operations (Create, Read, Update, Delete, List)
- Pure domain entities
- CQRS commands and queries
- Web controllers and forms
- Complete tests
Time: 15-20 minutes
Prerequisites
- Hexagonal Maker Bundle installed
- Doctrine configured with YAML mappings
- Database created
Step 1: Plan the Module
Module Structure
src/Blog/Post/
├── Domain/ # Business logic
├── Application/ # Use cases
├── Infrastructure/ # Technical implementations
└── UI/ # Controllers and forms
Bounded Context
- Context: Blog
- Module: Post
- Aggregate Root: Post entity
Step 2: Generate Domain Layer
2.1 Create Post Entity
bin/console make:hexagonal:entity blog/post Post
Generated:
src/Blog/Post/Domain/Model/Post.php
src/Blog/Post/Infrastructure/Persistence/Doctrine/Orm/Mapping/Post.orm.yml
Edit the entity:
<?php
// src/Blog/Post/Domain/Model/Post.php
declare(strict_types=1);
namespace App\Blog\Post\Domain\Model;
final class Post
{
private string $id;
private string $title;
private string $content;
private string $status; // draft, published
private \DateTimeImmutable $createdAt;
private ?\DateTimeImmutable $publishedAt;
public function __construct(
string $id,
string $title,
string $content
) {
$this->id = $id;
$this->title = $title;
$this->content = $content;
$this->status = 'draft';
$this->createdAt = new \DateTimeImmutable();
$this->publishedAt = null;
}
// Business method
public function publish(): void
{
if ($this->status === 'published') {
throw new \DomainException('Post is already published');
}
$this->status = 'published';
$this->publishedAt = new \DateTimeImmutable();
}
public function update(string $title, string $content): void
{
$this->title = $title;
$this->content = $content;
}
// Getters
public function getId(): string { return $this->id; }
public function getTitle(): string { return $this->title; }
public function getContent(): string { return $this->content; }
public function getStatus(): string { return $this->status; }
public function isPublished(): bool { return $this->status === 'published'; }
}
2.2 Complete Doctrine Mapping
# src/Blog/Post/Infrastructure/Persistence/Doctrine/Orm/Mapping/Post.orm.yml
App\Blog\Post\Domain\Model\Post:
type: entity
repositoryClass: App\Blog\Post\Infrastructure\Persistence\Doctrine\DoctrinePostRepository
table: post
id:
id:
type: string
length: 36
fields:
title:
type: string
length: 255
content:
type: text
status:
type: string
length: 20
options:
default: 'draft'
createdAt:
type: datetime_immutable
column: created_at
publishedAt:
type: datetime_immutable
column: published_at
nullable: true
2.3 Add Doctrine Mapping Configuration
Edit config/packages/doctrine.yaml:
doctrine:
orm:
mappings:
BlogPost:
is_bundle: false
type: yml
dir: '%kernel.project_dir%/src/Blog/Post/Infrastructure/Persistence/Doctrine/Orm/Mapping'
prefix: 'App\Blog\Post\Domain\Model'
alias: BlogPost
Step 3: Generate Repository
bin/console make:hexagonal:repository blog/post Post
Generated:
Domain/Port/PostRepositoryInterface.php(Interface)Infrastructure/Persistence/Doctrine/DoctrinePostRepository.php(Implementation)
Complete the repository interface:
<?php
// src/Blog/Post/Domain/Port/PostRepositoryInterface.php
namespace App\Blog\Post\Domain\Port;
use App\Blog\Post\Domain\Model\Post;
interface PostRepositoryInterface
{
public function save(Post $post): void;
public function findById(string $id): ?Post;
public function findAll(): array;
public function delete(Post $post): void;
}
Implement repository methods:
<?php
// src/Blog/Post/Infrastructure/Persistence/Doctrine/DoctrinePostRepository.php
namespace App\Blog\Post\Infrastructure\Persistence\Doctrine;
use App\Blog\Post\Domain\Model\Post;
use App\Blog\Post\Domain\Port\PostRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
final readonly class DoctrinePostRepository implements PostRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager
) {
}
public function save(Post $post): void
{
$this->entityManager->persist($post);
$this->entityManager->flush();
}
public function findById(string $id): ?Post
{
return $this->entityManager->find(Post::class, $id);
}
public function findAll(): array
{
return $this->entityManager
->getRepository(Post::class)
->findAll();
}
public function delete(Post $post): void
{
$this->entityManager->remove($post);
$this->entityManager->flush();
}
}
Step 4: Generate Use Cases (CQRS)
4.1 Create Post Command
bin/console make:hexagonal:command blog/post create --factory
Complete the command and handler logic following the CQRS pattern.
4.2 Update Post Command
bin/console make:hexagonal:command blog/post update
4.3 Delete Post Command
bin/console make:hexagonal:command blog/post delete
4.4 Find Post Query
bin/console make:hexagonal:query blog/post find-by-id
4.5 List Posts Query
bin/console make:hexagonal:query blog/post list-all
Step 5: Generate UI Layer
5.1 Controllers
# Create post
bin/console make:hexagonal:controller blog/post CreatePost /posts/new
# Edit post
bin/console make:hexagonal:controller blog/post EditPost /posts/{id}/edit
# Show post
bin/console make:hexagonal:controller blog/post ShowPost /posts/{id}
# List posts
bin/console make:hexagonal:controller blog/post ListPosts /posts
# Delete post
bin/console make:hexagonal:controller blog/post DeletePost /posts/{id}/delete
5.2 Forms
bin/console make:hexagonal:form blog/post Post
Step 6: Create Database Schema
# Validate mapping
bin/console doctrine:schema:validate
# Generate migration
bin/console doctrine:migrations:diff
# Execute migration
bin/console doctrine:migrations:migrate
Step 7: Test Your Module
Manual Testing via CLI
Create a test CLI command:
bin/console make:hexagonal:cli-command blog/post CreatePost app:post:create
Run:
bin/console app:post:create --title="My First Post" --content="Hello World"
Alternative: Use CRUD Maker âš¡
Instead of steps 4-5, generate everything at once:
bin/console make:hexagonal:crud blog/post Post --with-tests
This generates:
- Entity + Repository
- 5 Use Cases (Create, Update, Delete, Get, List)
- 5 Controllers + Form
- All tests
Next Steps
Congratulations! You’ve built a complete hexagonal module. Now:
- Add tests - Unit and integration tests
- Add validation - Input DTOs with constraints
- Add domain events - Event-driven architecture
- Explore all makers - Learn advanced features
Common Patterns
Validation
Use Input DTOs with Symfony Validator:
bin/console make:hexagonal:input blog/post CreatePostInput
<?php
namespace App\Blog\Post\Application\Input;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class CreatePostInput
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(min: 3, max: 255)]
public string $title,
#[Assert\NotBlank]
#[Assert\Length(min: 10)]
public string $content,
) {
}
}
Domain Events
Generate domain event:
bin/console make:hexagonal:domain-event blog/post PostPublished --with-subscriber
Dispatch from entity:
public function publish(): void
{
$this->status = 'published';
$this->publishedAt = new \DateTimeImmutable();
// Dispatch event
$this->recordEvent(new PostPublishedEvent($this->id, $this->title));
}
Async Processing
Generate message handler for async tasks:
bin/console make:hexagonal:message-handler blog/post SendNotification
Configure routing in messenger.yaml:
framework:
messenger:
routing:
'App\Blog\Post\Application\Message\SendNotificationMessage': async