DEV Community

Cover image for Stop Looking for Laravel's Service Container in Go
Gabriel Anhaia
Gabriel Anhaia

Posted on

Stop Looking for Laravel's Service Container in Go


Container withdrawal hits on day two of a fresh Go service. Your hand types app( and the editor offers nothing back. A quick search for composer require laravel/container returns the wrong ecosystem. The Go docs have no bind() function. The framework you trusted to wire your classes is gone.

This is the second-most common PHP-to-Go pain. The first is goroutines. The second is the moment your hand reaches for the container and finds an empty room.

Here is the thing nobody tells you. The container is not missing. The whole concept got moved into a single function called main. Go did not lose dependency injection. It removed the magic and left you with the mechanics.

Once you stop looking for the container, the language opens up. Boot is faster. The dependency graph fits in your head. Tests stop reaching for mocking helpers. You read one file and know how the program is built.

google/wire and uber-go/fx exist. Neither is the answer most services need.

What the Laravel container actually does

When you write a Laravel controller like this:

<?php

namespace App\Http\Controllers;

use App\Services\OrderService;

class OrderController extends Controller
{
    public function __construct(
        private OrderService $orders,
    ) {}

    public function store(Request $request)
    {
        $order = $this->orders->place($request->all());
        return response()->json($order, 201);
    }
}
Enter fullscreen mode Exit fullscreen mode

You never call new OrderController(). You never call new OrderService(). Laravel reads the constructor type-hints, reflects on them, walks the graph, and hands you a fully-wired instance. The framework calls this zero configuration resolution: if a class type-hints concrete classes the container can build, no registration is needed. Interfaces get bound in a service provider.

<?php

namespace App\Providers;

use App\Contracts\PaymentGateway;
use App\Services\StripeGateway;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(PaymentGateway::class, StripeGateway::class);

        $this->app->singleton(OrderService::class, function ($app) {
            return new OrderService(
                $app->make(PaymentGateway::class),
                $app->make(OrderRepository::class),
                config('orders.fee_cents'),
            );
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

You can also resolve manually with app()->make(OrderService::class) or the app(OrderService::class) helper. Both reach into the same global container and pull a constructed graph.

This works because PHP processes the request, builds the graph, serves the response, and dies. Under traditional FPM the container is rebuilt on every request, and the reflection cost is amortized across one HTTP cycle then thrown away. (Laravel Octane on Swoole or RoadRunner keeps the worker alive and reuses the container across requests, which changes that math but not the mental model below.) Laravel hides it well enough that most controllers feel like they have no constructor at all.

Why Go skipped the container

Go is not a request-per-process language. A Go binary boots once and stays up for weeks. There is no per-request reflection budget. There is one process, one dependency graph, and one place where the graph is constructed.

That place is main.

package main

import (
    "context"
    "database/sql"
    "log/slog"
    "net"
    "net/http"
    "os"

    _ "github.com/jackc/pgx/v5/stdlib"

    "example.com/orders/internal/adapter/http/handler"
    "example.com/orders/internal/adapter/postgres"
    "example.com/orders/internal/adapter/stripe"
    "example.com/orders/internal/domain/order"
)

func main() {
    ctx := context.Background()
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
    if err != nil {
        logger.Error("open db", "err", err)
        os.Exit(1)
    }
    defer db.Close()
Enter fullscreen mode Exit fullscreen mode

Now wire the adapters, the domain service, and the HTTP handler in dependency order:

    // Adapters satisfy domain ports.
    repo := postgres.NewOrderRepository(db)
    payments := stripe.NewGateway(os.Getenv("STRIPE_KEY"))

    svc := order.NewService(repo, payments, logger)
    h := handler.NewOrderHandler(svc, logger)

    mux := http.NewServeMux()
    mux.Handle("POST /orders", h.Create())

    srv := &http.Server{
        Addr:        ":8080",
        Handler:     mux,
        BaseContext: func(_ net.Listener) context.Context { return ctx },
    }
    logger.Info("listening", "addr", srv.Addr)
    if err := srv.ListenAndServe(); err != nil {
        logger.Error("serve", "err", err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

That is the entire DI story for a small service. Constructors return structs. main calls them in dependency order. The compiler refuses to build if you wired something wrong.

Look at what you got for free:

  • No reflection at boot. The binary jumps straight into ListenAndServe.
  • No service provider files. No register() methods. No tagged container singletons.
  • No global app() helper to leak into a unit test.
  • The graph is auditable in one screen of code.

You also got something the container hides: exposure. When the wiring is in your face, you notice that OrderService now takes seven dependencies and probably wants splitting. Laravel happily injects all seven and you never see the smell.

The constructor pattern that replaces bind

In Go, an interface lives next to the code that uses it, not next to the code that implements it. The domain owns the port. The adapter implements it. main is the only file that knows about both.

// internal/domain/order/service.go
package order

import (
    "context"
    "log/slog"
)

type Repository interface {
    Save(ctx context.Context, o Order) error
    FindByID(ctx context.Context, id string) (Order, error)
}

type PaymentGateway interface {
    Charge(ctx context.Context, amountCents int64, token string) (string, error)
}

type Service struct {
    repo     Repository
    payments PaymentGateway
    logger   *slog.Logger
}

func NewService(repo Repository, p PaymentGateway, l *slog.Logger) *Service {
    return &Service{repo: repo, payments: p, logger: l}
}
Enter fullscreen mode Exit fullscreen mode

The service consumes its ports through Place:

func (s *Service) Place(ctx context.Context, in PlaceInput) (Order, error) {
    chargeID, err := s.payments.Charge(ctx, in.AmountCents, in.Token)
    if err != nil {
        return Order{}, err
    }
    o := Order{ID: in.ID, ChargeID: chargeID, AmountCents: in.AmountCents}
    if err := s.repo.Save(ctx, o); err != nil {
        return Order{}, err
    }
    return o, nil
}
Enter fullscreen mode Exit fullscreen mode
// internal/adapter/postgres/order_repository.go
package postgres

import (
    "context"
    "database/sql"

    "example.com/orders/internal/domain/order"
)

type OrderRepository struct{ db *sql.DB }

func NewOrderRepository(db *sql.DB) *OrderRepository {
    return &OrderRepository{db: db}
}

func (r *OrderRepository) Save(ctx context.Context, o order.Order) error {
    _, err := r.db.ExecContext(ctx,
        `INSERT INTO orders (id, charge_id, amount_cents) VALUES ($1, $2, $3)`,
        o.ID, o.ChargeID, o.AmountCents,
    )
    return err
}

func (r *OrderRepository) FindByID(ctx context.Context, id string) (order.Order, error) {
    var o order.Order
    err := r.db.QueryRowContext(ctx,
        `SELECT id, charge_id, amount_cents FROM orders WHERE id = $1`, id,
    ).Scan(&o.ID, &o.ChargeID, &o.AmountCents)
    return o, err
}
Enter fullscreen mode Exit fullscreen mode

postgres.OrderRepository does not declare anywhere that it implements order.Repository. Go interfaces are satisfied structurally. As long as the methods match, the compiler accepts it when you pass it to order.NewService. If they do not match, the build fails at NewService(repo, ...) with a precise error pointing at the missing method.

This is the equivalent of Laravel's bind(PaymentGateway::class, StripeGateway::class). Except the binding lives in main, runs at compile time, and has zero runtime cost.

The test seam comparison

Here is where the container tax shows up clearly. In Laravel, replacing a binding for a test usually involves the container itself:

<?php

namespace Tests\Unit;

use App\Contracts\PaymentGateway;
use App\Services\OrderService;
use Tests\TestCase;
use Mockery;

class OrderServiceTest extends TestCase
{
    public function test_places_order_when_payment_succeeds(): void
    {
        $gateway = Mockery::mock(PaymentGateway::class);
        $gateway->shouldReceive('charge')
            ->once()
            ->andReturn('ch_123');

        $this->app->instance(PaymentGateway::class, $gateway);

        $orders = $this->app->make(OrderService::class);
        $order = $orders->place(/* ... */);

        $this->assertSame('ch_123', $order->charge_id);
    }
}
Enter fullscreen mode Exit fullscreen mode

You bootstrap the framework. You rebind the container. You ask the container for the service. The test depends on the framework being booted.

The Go version asks the container for nothing because there is no container:

package order_test

import (
    "context"
    "log/slog"
    "io"
    "testing"

    "example.com/orders/internal/domain/order"
)

type stubGateway struct{ id string }

func (s stubGateway) Charge(_ context.Context, _ int64, _ string) (string, error) {
    return s.id, nil
}

type stubRepo struct{ saved order.Order }

func (s *stubRepo) Save(_ context.Context, o order.Order) error {
    s.saved = o
    return nil
}

func (s *stubRepo) FindByID(_ context.Context, id string) (order.Order, error) {
    return s.saved, nil
}
Enter fullscreen mode Exit fullscreen mode

Two short stubs replace the mocking library. The test itself just builds the service and calls Place:

func TestPlaceOrderChargesAndSaves(t *testing.T) {
    repo := &stubRepo{}
    gw := stubGateway{id: "ch_123"}
    svc := order.NewService(repo, gw, slog.New(slog.NewTextHandler(io.Discard, nil)))

    o, err := svc.Place(context.Background(), order.PlaceInput{
        ID: "o_1", AmountCents: 1999, Token: "tok_visa",
    })
    if err != nil {
        t.Fatal(err)
    }
    if o.ChargeID != "ch_123" {
        t.Fatalf("got %q, want ch_123", o.ChargeID)
    }
}
Enter fullscreen mode Exit fullscreen mode

No framework boot. No container rebind. No mocking library required: a five-line struct does the job. The test runs in microseconds because there is no dependency tree to construct beyond the three things it actually needs.

This is the part that tends to convert PHP devs. The container was helping you fake things. Once it is gone, the tests get faster and the seams get smaller, because the dependencies were always supposed to be passed in. The container just hid the act.

When the wiring gets bigger

Reasonable concern. "Sure, three deps fit in main. What about thirty?"

The answer most Go services land on is: keep it explicit until it hurts, then group it. A 200-line main.go is not a problem. A 600-line main.go is a sign that you should pull the wiring into helper constructors that group related adapters:

type Adapters struct {
    Repo     order.Repository
    Payments order.PaymentGateway
    Mailer   notifications.Mailer
}

func buildAdapters(cfg Config, db *sql.DB) Adapters {
    return Adapters{
        Repo:     postgres.NewOrderRepository(db),
        Payments: stripe.NewGateway(cfg.StripeKey),
        Mailer:   ses.NewMailer(cfg.AWSRegion),
    }
}
Enter fullscreen mode Exit fullscreen mode

Then main calls buildAdapters, buildServices, buildHandlers. Three function calls, all explicit, all compile-checked, no reflection. This is the pattern you find in most large Go services in production today.

You hold the line at manual constructors for as long as you can, because the moment you reach for a framework, you re-import the things Go was protecting you from.

What about google/wire and uber-go/fx?

These are the two names you will hear when you ask "but isn't there a real DI framework for Go?" There are. They solve different problems and most services do not need either.

google/wire is a code generator. You declare provider sets, run wire once, and it writes the main.go-style wiring code for you at compile time. The generated code is plain Go: nothing wire-specific runs at runtime, and the binary has no reflection cost. The Go team's announcement post describes it as "compile-time dependency injection" (go.dev/blog/wire).

It is useful when your wiring file gets long enough that a typo in one constructor's argument list costs you fifteen minutes of compile-error-chasing. The tradeoff is one more build step and one more thing to teach a new hire.

uber-go/fx is the closest thing Go has to Laravel's container. It uses reflection at runtime to build the graph, supports lifecycle hooks (OnStart, OnStop), and bundles a dependency-graph-aware logger. Wire does at compile time what Fx does at boot, so Fx pays a startup cost Wire does not. As one Leapcell measurement reports, the gap on resolution time alone runs to several orders of magnitude in Wire's favor (Leapcell on Wire vs Fx).

That sounds worse than it is. Go binaries boot fast enough that an extra reflection pass at startup is a one-time cost a long-running service rarely notices.

The honest call:

  • Default: write main.go by hand. It scales further than you think.
  • Reach for wire when the wiring file becomes tedious and you want a code-gen safety net. Output is still plain Go.
  • Reach for fx when you need lifecycle management across many components: a service with twenty subsystems that each need ordered start/stop, health checks, and shared shutdown hooks. Uber built it for exactly that case.

Both tools are extensions of the same idea, not replacements for it. Neither makes the dependency graph go away. They write parts of it for you.

What container withdrawal actually feels like

The first week, you miss it. You catch yourself typing app( and remembering it does not exist. You write three lines of constructor calls where Laravel had zero. It feels like a regression.

By the second week, the container's cost finally surfaces. It was never CPU. It was cognitive load and the slow accretion of magic nobody owns. You read someone else's Go service and trace every dependency in five minutes because there is no provider, no facade, no service-locator-disguised-as-helper to chase.

After that, you stop looking for the container and start writing constructors with intent. You notice when a struct has too many dependencies, because you can see all of them lined up in main. You delete the ones that should not be there.

PHP buried the wiring under a framework. Go put it back where it belongs.

The smallest commitment you can make today

If you are mid-migration and want to feel the shift without committing to a full rewrite:

  1. Pick the smallest service you have. One handler, one repository, one external call.
  2. Write main.go from scratch. No framework. No DI library.
  3. Define the ports as interfaces in the package that uses them, not the package that implements them.
  4. Wire the constructors top-down in main.
  5. Write one test for the service using a five-line stub instead of a mocking library.

That is the entire pattern. Once it clicks for one service, the next ten feel obvious. The container stays in PHP, where it is genuinely useful given how PHP processes work, and Go stays in the place it has always been: honest about what your program is actually doing on boot.

You do not need a service container. You need a main.go you trust.


If this matched how you think (explicit wiring, ports defined where they are used, tests that do not boot a framework), the architectural side is the second book in the series.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)