Getting Started

Coming from Laravel

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

If you've worked with Laravel, most of gpsystem's building blocks will feel familiar: several packages were explicitly modeled on their Laravel counterparts. This page shows what maps to what, and honestly, what does not transfer.

What carries over: the way you think about events, listeners, policies, scheduling and queues transfers one to one. What doesn't: there is no ORM magic (no Eloquent, repositories contain SQL or the bun query builder), no container autowiring (dependencies travel in an explicit Dependencies struct that you fill in main.go), and no global facades either: every dependency is visible and traceable.

The mapping table

LaravelgpsystemWhere
Task Scheduling ($schedule->daily())scheduler.Schedule fluent API (sched.DailyAt(...), sched.Job(...))Scheduler
Events & Listeners, ShouldQueueevents.Event + events.Listen (every listener is an independent, retried task)Events
Queues, queue:work, Horizonqueue (asynq + Redis) + the cmd/worker binaryQueue, Worker
Policies ($this->authorize())policy.Registry + generated enforcer middleware, fail-closedPolicies
Gates, roles (spatie/permission)rbac.Identity, RequireRole / RequirePermissionRBAC
Auth guards, Sanctum tokensauth (JWT issuing and middleware)Authentication
Monolog (dev channel)logx/monolog (a Monolog-format console handler)Logging
php artisan migratego run ./cmd/migrate (embedded, numbered SQL)Migrations
DB::transaction(fn)Transactor.WithinTransaction(ctx, fn) (the transaction travels in the context)Transactions
FormRequest rulesvalidator struct tags + generated request typesValidation
Exception render() / report()errs codes + public message → RFC 9457 problem+json / SentryError model
config() + .envenvconf.MustLoad[T] (a typed struct from env, with .env support)Configuration
Storage facade, flysystem disksthe storage.Store interface + an S3 implementationStorage
Mailables, markdown mailthe mail.Message builder + MJML templates; Mail::fake() ↔ the memory mailerMail
php artisan make:*go tool gpsystem new/add ...CLI
paginate() / cursorPaginate()paginate.Page[T] / paginate.CursorPage[T]Pagination
Eloquent ORMpgx (SQL) or bun (query builder), no ActiveRecord simulationDatabase
Route registration (routes/api.php, auto-discovery)Register<Surface>(router, deps) (an explicit call in main.go)Architecture
A route group / separate controller set per audience (public API vs admin)a surface (per-audience bundle): surfaces/<surface>/, with its own contract and middlewareadd surface
Model methods + shared service logicthe module's core/ package (rules written once, called by every surface)Project structure
The query layer (Eloquent scopes, query builder calls)the module-level repository/: one per module, shared by every surfaceProject structure

Two examples side by side

A scheduled task: the behavior of ->onOneServer() is the default here (Redis-lease leader election):

Schedule::command('report:nightly-sales')
    ->dailyAt('03:00')
    ->onOneServer();

A policy (per-request ownership checks for when a role alone isn't enough):

class OrderPolicy
{
    public function view(User $user, Order $order): bool
    {
        return $user->id === $order->user_id
            || $user->hasRole('admin');
    }
}

One substantive difference: in Laravel you call the policy from the controller ($this->authorize()), and it 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).

Behavioral differences

The mapping table shows what corresponds to what. This section shows where the behavior is not the same once you look past the surface similarity.

  • Transactions. In Laravel, the transaction is tracked by a global connection manager: any model call automatically joins it, and DB::transaction retries on deadlock (the attempts parameter); rollback is triggered by an exception. In gpsystem the transaction travels in the context (Transactor.WithinTransaction): only a call that receives the closure's ctx participates; there's no automatic deadlock retry (that decision is yours); rollback is triggered by returning a plain error, not an exception. Details: Transactions.
  • Migrations. The goose_db_version table is the counterpart of Laravel's migrations table, but there's no schema builder: plain SQL runs. This is a deliberate trade-off: the builder buys cross-database portability that isn't needed here (the kit is built on PostgreSQL), at the cost of hiding the actual DDL. Plain SQL exposes the full surface of PostgreSQL (partial indexes, GENERATED columns, CTE-based backfills) that a schema builder could only reach via DB::statement. Details: Migrations.
  • Events and fan-out. 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. That's the essential departure from Laravel's synchronous event loop (where one listener's exception can halt the rest), and the price is an idempotency requirement on every listener. Details: Events.
  • Transactional outbox. Laravel has no built-in answer to the dual-write problem: afterCommit handles the rollback case, but doesn't close the post-commit crash window (the process dying between the DB commit and the event enqueue); anyone wanting a guarantee pulls in a separate outbox package there too. gpsystem ships this by default: the outbox writes the event into the same database, in the same transaction, as the business change. Details: Outbox.
  • OpenTelemetry. There's no real PHP equivalent for this: in the Laravel ecosystem, Telescope is dev-only and production APM is the territory of paid services (Blackfire, New Relic). In the Go ecosystem, distributed tracing is standard, vendor-neutral infrastructure, which the kit switches on with one call. Details: OpenTelemetry.

What's missing, and why

  • No Eloquent. The repository layer works with pgx (SQL) or bun (query builder), explicit queries are the established path in the Go ecosystem, and the kit doesn't try to simulate ActiveRecord. bun is an optional adapter if you want query-builder comfort.
  • No service container. 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 Blade. 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 Laravel: 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, in the conceptual frame you know from Laravel. That's why you see the parallels everywhere, and that's why the kit doesn't hide the libraries underneath: 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