Getting Started

Coming from Symfony

A concept-by-concept mapping between Symfony and gpsystem.

If you've worked with Symfony, gpsystem's way of thinking will feel familiar: the concepts behind Messenger, the EventDispatcher and Voters transfer one to one. This page shows what maps to what, and honestly, what does not transfer.

What carries over: message-based async processing, the event/listener way of thinking, and per-request authorization checks. What doesn't: there is no service container or autowiring (dependencies travel in an explicit Dependencies struct that you fill in main.go), no bundle system, and no Doctrine ORM: repositories contain SQL or the bun query builder.

The mapping table

SymfonygpsystemWhere
The Scheduler component (#[AsPeriodicTask])scheduler.Schedule fluent API (sched.DailyAt(...), sched.Job(...))Scheduler
Messenger (#[AsMessageHandler])events.Event + events.Listen (every listener is an independent, retried task)Events
Messenger transports, the worker (messenger:consume)queue (asynq + Redis) + the cmd/worker binaryQueue, Worker
Security Voters (voteOnAttribute)policy.Registry + generated enforcer middleware, fail-closedPolicies
Role hierarchy, ROLE_*rbac.Identity, RequireRole / RequirePermissionRBAC
The security firewall, authenticatorsauth (JWT issuing and middleware)Authentication
MonologBundle (dev channel)logx/monolog (a Monolog-format console handler)Logging
Doctrine Migrations (doctrine:migrations:migrate)go run ./cmd/migrate (embedded, numbered SQL)Migrations
EntityManager::wrapInTransaction()Transactor.WithinTransaction(ctx, fn) (the transaction travels in the context)Transactions
Validator attributes (#[Assert\NotBlank])validator struct tags + generated request typesValidation
Exception listeners, ExceptionEventerrs codes + public message → RFC 9457 problem+json / SentryError model
config/packages/*.yaml + .envenvconf.MustLoad[T] (a typed struct from env, with .env support)Configuration
The Flysystem bundle (oneup)the storage.Store interface + an S3 implementationStorage
The Mailer componentthe mail.Message builder + MJML templates; the in-memory mailer is the test doubleMail
The Notifier componentnotify (multi-channel notifications)Notifications
Mercurerealtime (a first-party publish/subscribe)Realtime
MakerBundle (make:*)go tool gpsystem new/add ...CLI
Pagerfanta / KnpPaginatorBundlepaginate.Page[T] / paginate.CursorPage[T]Pagination
Doctrine ORMpgx (SQL) or bun (query builder), no ActiveRecord/Doctrine-style simulationDatabase
Route attributes (#[Route]), auto-discoveryRegister<Surface>(router, deps) (an explicit call in main.go)Architecture
A separate controller set per audience (public API vs admin behind a firewall)a surface (per-audience bundle): surfaces/<surface>/, with its own contract and middlewareadd surface
Entity methods + shared service logicthe module's core/ package (rules written once, called by every surface)Project structure
Doctrine repositoriesthe module-level repository/: one per module, shared by every surfaceProject structure

Two examples side by side

A Messenger handler: processing a domain message:

#[AsMessageHandler]
final class OrderPlacedHandler
{
    public function __invoke(OrderPlaced $message): void
    {
        $this->mailer->send(
            (new TemplatedEmail())
                ->to($message->email)
                ->htmlTemplate('emails/order_confirmation.html.twig')
                ->context(['orderId' => $message->orderId]),
        );
    }
}

An authorization check: per-request ownership checks for when a role alone isn't enough:

class OrderVoter extends Voter
{
    protected function voteOnAttribute(string $attribute, mixed $order, TokenInterface $token): bool
    {
        $user = $token->getUser();
        return $order->getUserId() === $user->getId()
            || in_array('ROLE_ADMIN', $user->getRoles(), true);
    }
}

One substantive difference: a Symfony Voter can also respond with abstain (declining to decide, deferring to the rest of the chain), and the denyAccessUnlessGranted() call in the controller can be forgotten. Here the policy is attached to the operation by the @policy decorator in the TypeSpec contract, and the enforcer middleware enforces it from the generated table. An operation that is tagged but has no registered policy doesn't slip through, it errors (fail-closed); there is no "abstain" state.

Behavioral differences

  • Events and fan-out. Symfony Messenger routes one message to one handler (multiple handlers can be registered, but typically one retry configuration applies to the message). gpsystem treats every listener as an independent asynq task with its own retry budget: one event fans out into N listener tasks, and a failing listener's retry never re-runs the others. Details: Events.
  • Transactional outbox. Doctrine's EntityManager::wrapInTransaction() handles the pre-commit rollback case, but doesn't close the post-commit crash window (the process dying between the DB commit and the message dispatch). gpsystem's outbox writes the event into the same database, in the same transaction, as the business change, by default. Details: Outbox.
  • Migrations. Doctrine Migrations has a schema-diff generator (doctrine:migrations:diff); gpsystem doesn't, only plain, hand-written SQL, a deliberate trade-off against the diff generator's tendency to hide the actual DDL. Details: Migrations.

What's missing, and why

  • No service container or autowiring. The Dependencies struct is built by hand in main.go. In exchange, the answer to "where does this dependency come from?" is always a line in cmd/<module>/main.go.
  • No Doctrine ORM. The repository layer works with pgx (SQL) or bun (query builder). Explicit queries are the established path in the Go ecosystem. bun is an optional adapter if you want query-builder comfort.
  • No Twig. The kit focuses on API backends; server-side rendering is not part of it.

Background

The kit author's motivation was precisely to have a system in the Go ecosystem that is as easy to pick up as the batteries-included PHP frameworks: one where on day one of a project you write business logic, not evaluate infrastructure. The Go world's strength lies in small, independent, well-tested libraries: gpsystem doesn't want to replace them, but to offer a single, reconciled and maintained composition of them. The library half is a microservice chassis, the generated project a service template (Richardson), not a runtime layer living above your app. The goal is a comfortable start, not a new, closed framework.

Copyright © 2026