Coming from Laravel
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
| Laravel | gpsystem | Where |
|---|---|---|
Task Scheduling ($schedule->daily()) | scheduler.Schedule fluent API (sched.DailyAt(...), sched.Job(...)) | Scheduler |
Events & Listeners, ShouldQueue | events.Event + events.Listen (every listener is an independent, retried task) | Events |
Queues, queue:work, Horizon | queue (asynq + Redis) + the cmd/worker binary | Queue, Worker |
Policies ($this->authorize()) | policy.Registry + generated enforcer middleware, fail-closed | Policies |
| Gates, roles (spatie/permission) | rbac.Identity, RequireRole / RequirePermission | RBAC |
| Auth guards, Sanctum tokens | auth (JWT issuing and middleware) | Authentication |
| Monolog (dev channel) | logx/monolog (a Monolog-format console handler) | Logging |
php artisan migrate | go run ./cmd/migrate (embedded, numbered SQL) | Migrations |
DB::transaction(fn) | Transactor.WithinTransaction(ctx, fn) (the transaction travels in the context) | Transactions |
| FormRequest rules | validator struct tags + generated request types | Validation |
Exception render() / report() | errs codes + public message → RFC 9457 problem+json / Sentry | Error model |
config() + .env | envconf.MustLoad[T] (a typed struct from env, with .env support) | Configuration |
| Storage facade, flysystem disks | the storage.Store interface + an S3 implementation | Storage |
| Mailables, markdown mail | the mail.Message builder + MJML templates; Mail::fake() ↔ the memory mailer | |
php artisan make:* | go tool gpsystem new/add ... | CLI |
paginate() / cursorPaginate() | paginate.Page[T] / paginate.CursorPage[T] | Pagination |
| Eloquent ORM | pgx (SQL) or bun (query builder), no ActiveRecord simulation | Database |
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 middleware | add surface |
| Model methods + shared service logic | the 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 surface | Project 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();
func Register(sched *scheduler.Schedule, deps shop.Dependencies) {
sched.Job("shop.nightlySalesReport", "0 3 * * *",
nightlySalesReport(deps))
}
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');
}
}
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: 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::transactionretries on deadlock (theattemptsparameter); rollback is triggered by an exception. In gpsystem the transaction travels in the context (Transactor.WithinTransaction): only a call that receives the closure'sctxparticipates; there's no automatic deadlock retry (that decision is yours); rollback is triggered by returning a plainerror, not an exception. Details: Transactions. - Migrations. The
goose_db_versiontable is the counterpart of Laravel'smigrationstable, 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,GENERATEDcolumns, CTE-based backfills) that a schema builder could only reach viaDB::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:
afterCommithandles 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
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 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.