ahmed-bhs/hexagonal-maker-bundle
Complete step-by-step guide to build your first hexagonal module from scratch.
A Blog Post module with:
Time: 15-20 minutes
src/Blog/Post/
├── Domain/ # Business logic
├── Application/ # Use cases
├── Infrastructure/ # Technical implementations
└── UI/ # Controllers and forms
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'; }
}
# 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
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
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();
}
}
bin/console make:hexagonal:command blog/post create --factory
Complete the command and handler logic following the CQRS pattern.
bin/console make:hexagonal:command blog/post update
bin/console make:hexagonal:command blog/post delete
bin/console make:hexagonal:query blog/post find-by-id
bin/console make:hexagonal:query blog/post list-all
# 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
bin/console make:hexagonal:form blog/post Post
# Validate mapping
bin/console doctrine:schema:validate
# Generate migration
bin/console doctrine:migrations:diff
# Execute migration
bin/console doctrine:migrations:migrate
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"
Instead of steps 4-5, generate everything at once:
bin/console make:hexagonal:crud blog/post Post --with-tests
This generates:
Congratulations! You've built a complete hexagonal module. Now:
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,
) {
}
}
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));
}
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
How can I help you explore Laravel packages today?