You do not need to understand every Assegai term before you start. This guide introduces the moving pieces in the order a normal app needs them.
It walks through the kind of workflow that makes Assegai feel fast in practice:
- scaffold a new app
- generate a resource
- turn the generated placeholders into a real ORM-backed feature
- add validation at the edge
- generate a page
- grow the app by composing modules
The point is not just to produce a posts endpoint. It is to show why the framework's design choices help once the app becomes more than a demo.
Start from a new app
Create and enter a new project:
assegai new publishing-suite
cd publishing-suite
assegai serve
At this point you already have:
- a root
index.phprequest entry point - a
bootstrap.phpthat creates the app throughAssegaiFactory - a root module
- a starter controller and service
- a rendered home page
That is a productive starting state, not just an empty shell.
Generate a resource in one command
Now generate a posts feature:
assegai g r posts
When you run that from the project root, the CLI creates the feature and updates AppModule for you.
flowchart LR
A["assegai g r posts"] --> B["Generate PostsController"]
A --> C["Generate PostsService"]
A --> D["Generate PostsModule"]
A --> E["Generate DTOs and Entity"]
A --> F["Update AppModule imports"]
F --> G["Feature becomes reachable through the running app"]
You now have a route surface immediately:
GET /postsGET /posts/:idPOST /postsPUT /posts/:idDELETE /posts/:id
The generated service methods still return placeholder strings, but the feature is already wired into the app and reachable through HTTP. That is the "move fast, refine deliberately" workflow Assegai is aiming for.
Understand the generated shape
The generated controller is intentionally thin:
<?php
namespace Assegaiphp\PublishingSuite\Posts;
use Assegai\Core\Attributes\Controller;
use Assegai\Core\Attributes\Http\Body;
use Assegai\Core\Attributes\Http\Delete;
use Assegai\Core\Attributes\Http\Get;
use Assegai\Core\Attributes\Http\Post;
use Assegai\Core\Attributes\Http\Put;
use Assegai\Core\Attributes\Param;
use Assegaiphp\PublishingSuite\Posts\DTOs\CreatePostDTO;
use Assegaiphp\PublishingSuite\Posts\DTOs\UpdatePostDTO;
#[Controller('posts')]
readonly class PostsController
{
public function __construct(private PostsService $postsService)
{
}
#[Get]
public function findAll(): string
{
return $this->postsService->findAll();
}
#[Get(':id')]
public function findById(#[Param('id')] int $id): string
{
return $this->postsService->findById($id);
}
#[Post]
public function create(#[Body] CreatePostDTO $dto): string
{
return $this->postsService->create($dto);
}
}
That shape tells you what belongs where:
- routing belongs to the controller
- business behavior belongs to the provider
- request shape belongs to DTOs
- persistence shape belongs to entities
Upgrade the DTOs from placeholders to contracts
The generated DTOs start empty on purpose. Fill them in with the request shape you actually want:
<?php
namespace Assegaiphp\PublishingSuite\Posts\DTOs;
use Assegai\Core\Attributes\Injectable;
use Assegai\Validation\Attributes\IsNotEmpty;
use Assegai\Validation\Attributes\IsString;
#[Injectable]
class CreatePostDTO
{
#[IsString]
#[IsNotEmpty]
public string $title = '';
#[IsString]
#[IsNotEmpty]
public string $body = '';
}
The app API also exposes a global pipe registration path in bootstrap.php:
<?php
use Assegai\Core\AssegaiFactory;
use Assegai\Core\Pipes\ValidationPipe;
use Assegaiphp\PublishingSuite\AppModule;
require './vendor/autoload.php';
function bootstrap(): void
{
$app = AssegaiFactory::create(AppModule::class);
$app->useGlobalPipes(new ValidationPipe());
$app->run();
}
bootstrap();
If your app version uses that global pipe path, it keeps request validation near the transport boundary instead of repeating checks deep in services.
The clearest request-time pipe flow I verified directly in the current core is decorator-bound handling on request parameters like #[Body(pipes: ...)], so that is still a good mental model to keep in mind.
Turn the entity into a real model
The generated entity gives you an id. The next step is to describe real columns and point the entity at a configured data source:
<?php
namespace Assegaiphp\PublishingSuite\Posts\Entities;
use Assegai\Orm\Attributes\Columns\Column;
use Assegai\Orm\Attributes\Columns\PrimaryGeneratedColumn;
use Assegai\Orm\Attributes\Entity;
use Assegai\Orm\Queries\Sql\ColumnType;
use Assegai\Orm\Traits\ChangeRecorderTrait;
#[Entity(
table: 'posts',
database: 'blog',
)]
class PostEntity
{
use ChangeRecorderTrait;
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $title = '';
#[Column(type: ColumnType::TEXT)]
public string $body = '';
}
The database name should match the connection configured in config/default.php.
If you want the whole feature to share the same default connection, you can also put it on the module:
<?php
namespace Assegaiphp\PublishingSuite\Posts;
use Assegai\Core\Attributes\Modules\Module;
#[Module(
providers: [PostsService::class],
controllers: [PostsController::class],
config: ['data_source' => 'blog'],
)]
class PostsModule
{
}
That gives you a good spectrum of choices:
- app-wide default on
AppModule - feature default on
PostsModule - explicit override on
PostEntity
Replace placeholders with a repository-backed service
Once the entity is ready, the service becomes real application code:
<?php
namespace Assegaiphp\PublishingSuite\Posts;
use Assegai\Core\Attributes\Injectable;
use Assegai\Orm\Attributes\InjectRepository;
use Assegai\Orm\Management\Repository;
use Assegai\Orm\Queries\QueryBuilder\Results\FindResult;
use Assegai\Orm\Queries\QueryBuilder\Results\InsertResult;
use Assegaiphp\PublishingSuite\Posts\DTOs\CreatePostDTO;
use Assegaiphp\PublishingSuite\Posts\Entities\PostEntity;
#[Injectable]
class PostsService
{
public function __construct(
#[InjectRepository(PostEntity::class)]
private Repository $postsRepository,
) {
}
public function findAll(): FindResult
{
return $this->postsRepository->find([
'order' => ['id' => 'DESC'],
'limit' => 20,
'skip' => 0,
]);
}
public function create(CreatePostDTO $dto): InsertResult
{
$post = $this->postsRepository->create([
'title' => $dto->title,
'body' => $dto->body,
]);
return $this->postsRepository->insert($post);
}
}
Two design benefits show up here:
- dependency injection keeps the service easy to test
- the controller does not need to know anything about persistence details
Let the controller stay simple
After that change, the controller can return ORM results directly:
<?php
namespace Assegaiphp\PublishingSuite\Posts;
use Assegai\Core\Attributes\Controller;
use Assegai\Core\Attributes\Http\Body;
use Assegai\Core\Attributes\Http\Get;
use Assegai\Core\Attributes\Http\Post;
use Assegai\Core\Attributes\Param;
use Assegai\Orm\Queries\QueryBuilder\Results\FindResult;
use Assegai\Orm\Queries\QueryBuilder\Results\InsertResult;
use Assegaiphp\PublishingSuite\Posts\DTOs\CreatePostDTO;
#[Controller('posts')]
readonly class PostsController
{
public function __construct(private PostsService $postsService)
{
}
#[Get]
public function findAll(): FindResult
{
return $this->postsService->findAll();
}
#[Get(':id<int>')]
public function findById(#[Param('id')] int $id): FindResult
{
return $this->postsService->findById($id);
}
#[Post]
public function create(#[Body] CreatePostDTO $dto): InsertResult
{
return $this->postsService->create($dto);
}
}
That is a good example of how Assegai helps you keep controllers declarative instead of procedural.
Add a page without leaving the same architecture
API work is only one side of the framework. If you also want an about page, generate it:
assegai g pg about
That creates a dedicated module with:
AboutControllerAboutServiceAboutComponent- a Twig template
- a CSS file
The page becomes part of the same modular graph as the API:
flowchart TD
AppModule --> PostsModule
AppModule --> AboutModule
PostsModule --> PostsController
PostsModule --> PostsService
PostsService --> PostEntity
AboutModule --> AboutController
AboutModule --> AboutService
AboutModule --> AboutComponent
This is one of Assegai's strongest ideas: JSON endpoints and rendered pages do not need different architectural systems.
Grow into nested modules when the app gets larger
Once a project starts to split into public and internal areas, nested modules help keep route structure readable.
<?php
namespace Assegaiphp\PublishingSuite\Admin;
use Assegai\Core\Attributes\Controller;
use Assegai\Core\Attributes\Http\Get;
use Assegai\Core\Attributes\Modules\Module;
#[Controller('admin')]
class AdminController
{
#[Get]
public function index(): array
{
return ['area' => 'admin'];
}
}
#[Controller('audit')]
class AuditController
{
#[Get]
public function index(): array
{
return ['area' => 'audit'];
}
}
#[Module(
controllers: [AuditController::class],
)]
class AuditModule
{
}
#[Module(
controllers: [AdminController::class],
imports: [AuditModule::class],
)]
class AdminModule
{
}
Imported into AppModule, that gives you a route branch like:
GET /adminGET /admin/audit
That is a good moment to appreciate why Assegai is so module-driven: route structure and code ownership evolve together.
The practical takeaway
Assegai is at its best when you let the CLI create the shape quickly and then refine the generated code feature by feature.
A productive default workflow is:
- scaffold the app with
assegai new - generate features with
assegai g r ...andassegai g pg ... - keep controllers thin
- put use-case logic in providers
- model input with DTOs
- add validation at the request boundary
- add ORM repositories when the feature needs persistence
- compose growth through modules instead of ad-hoc wiring
That combination is what makes Assegai useful beyond small demos: it is quick at the start, but it also gives you a clean path into larger applications.