Coming from Symfony
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
| Symfony | gpsystem | Where |
|---|---|---|
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 binary | Queue, Worker |
Security Voters (voteOnAttribute) | policy.Registry + generated enforcer middleware, fail-closed | Policies |
Role hierarchy, ROLE_* | rbac.Identity, RequireRole / RequirePermission | RBAC |
| The security firewall, authenticators | auth (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 types | Validation |
Exception listeners, ExceptionEvent | errs codes + public message → RFC 9457 problem+json / Sentry | Error model |
config/packages/*.yaml + .env | envconf.MustLoad[T] (a typed struct from env, with .env support) | Configuration |
| The Flysystem bundle (oneup) | the storage.Store interface + an S3 implementation | Storage |
| The Mailer component | the mail.Message builder + MJML templates; the in-memory mailer is the test double | |
| The Notifier component | notify (multi-channel notifications) | Notifications |
| Mercure | realtime (a first-party publish/subscribe) | Realtime |
MakerBundle (make:*) | go tool gpsystem new/add ... | CLI |
| Pagerfanta / KnpPaginatorBundle | paginate.Page[T] / paginate.CursorPage[T] | Pagination |
| Doctrine ORM | pgx (SQL) or bun (query builder), no ActiveRecord/Doctrine-style simulation | Database |
Route attributes (#[Route]), auto-discovery | Register<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 middleware | add surface |
| Entity methods + shared service logic | the module's core/ package (rules written once, called by every surface) | Project structure |
| Doctrine repositories | the module-level repository/: one per module, shared by every surface | Project 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]),
);
}
}
func sendOrderConfirmation(deps shop.Dependencies) func(context.Context, shopevents.OrderPlaced) error {
return func(ctx context.Context, ev shopevents.OrderPlaced) error {
msg := mail.NewMessage().
WithTo(ev.Email).
WithSubject("Your order confirmation").
WithBody(mjml.Template(templates, "templates/order_confirmation.mjml.tmpl",
map[string]any{"OrderID": ev.OrderID}))
return deps.Mailer.Send(ctx, msg)
}
}
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);
}
}
func New() *kitpolicy.Registry {
reg := kitpolicy.NewRegistry()
reg.Register("orders.view", func(ctx context.Context, id *rbac.Identity, req any) error {
if id.HasRole("admin") {
return nil
}
r := req.(gen.GetOrderRequest)
if !ownsOrder(ctx, id, r.OrderId) {
return kitpolicy.Deny("You can only view your own orders.")
}
return nil
})
return reg
}
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
Dependenciesstruct is built by hand inmain.go. In exchange, the answer to "where does this dependency come from?" is always a line incmd/<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.