DEV Community

Cover image for Doppar vs Laravel: Why Modern Attribute-Based DI is Winning Over Traditional Service Containers
Francisco Navarro
Francisco Navarro

Posted on

Doppar vs Laravel: Why Modern Attribute-Based DI is Winning Over Traditional Service Containers

Dependency Injection containers are the backbone of modern PHP frameworks. They determine how clean your code looks, how testable it becomes, and ultimately, how much you'll enjoy working with the framework day-to-day. Laravel has long been the gold standard with its elegant container implementation, but Doppar is challenging that position with a fresh, attribute-driven approach that feels more aligned with modern PHP development.

Let me walk you through why Doppar's service container might just be the evolution we've been waiting for.

The Laravel Approach: Powerful but Verbose

Laravel's service container is genuinely excellent. It's mature, battle-tested, and incredibly powerful. When you need to bind an interface to a concrete implementation, you typically head to a service provider:

class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind(
            UserRepositoryInterface::class,
            UserRepository::class
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

This works beautifully and has served millions of applications well. But here's where things get interesting—and where Doppar starts to shine.

Doppar also support this style

The Problem with Centralized Binding

In Laravel, your bindings live in service providers, separated from where they're actually used. This creates a mental overhead. When you're looking at a controller method, you can't immediately see which concrete class is being injected. You have to:

  1. Open your service provider files
  2. Search through potentially dozens of bindings
  3. Hope the binding is where you expect it to be
  4. Remember this context when you return to your controller

For small projects, this is manageable. But as your application grows to hundreds of classes and interfaces, this centralized approach becomes a game of hide-and-seek with your own code.

Enter Doppar: Context is King

Doppar takes a radically different approach. Instead of hiding your bindings away in service providers, it brings them right to where you need them—using PHP 8 attributes.

Parameter-Level Binding with #[Bind]

Here's where Doppar gets really interesting. You can bind dependencies exactly where you use them:

#[Route(uri: 'user/store', methods: ['POST'])]
public function store(
    #[Bind(UserRepository::class)] UserRepositoryInterface $userRepository
) {
    // The binding is right here, crystal clear
    $userRepository->save($data);
}
Enter fullscreen mode Exit fullscreen mode

Look at how explicit this is. Anyone reading this code knows exactly which concrete class is being injected. No hunting through service providers. No mental gymnastics. The dependency and its resolution are in the same place.

Method-Level Binding with #[Resolver]

Sometimes you want a binding that applies to an entire method but might vary between methods:

class UserController extends Controller
{
    #[Resolver(abstract: UserRepositoryInterface::class, concrete: UserRepository::class)]
    public function index(UserRepositoryInterface $userRepository): Response
    {
        return $userRepository->getUsers();
    }

    #[Resolver(abstract: UserRepositoryInterface::class, concrete: CachedUserRepository::class)]
    public function dashboard(UserRepositoryInterface $userRepository): Response
    {
        // Same interface, different implementation
        return $userRepository->getDashboardData();
    }
}
Enter fullscreen mode Exit fullscreen mode

This is impossible to achieve cleanly in Laravel without creating separate bindings and manually resolving them. Doppar makes contextual binding trivial.

Class-Level Binding

When you want consistent behavior across an entire controller, Doppar has you covered:

#[Resolver(UserRepositoryInterface::class, UserRepository::class)]
class UserController extends Controller
{
    public function index(UserRepositoryInterface $user): Response 
    {
        return $user->getUsers();
    }

    public function show(int $id, UserRepositoryInterface $user): Response
    {
        return $user->find($id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Every method in this controller automatically gets the same binding. It's visible, it's explicit, and it requires zero configuration elsewhere.

The Real-World Difference

Let's compare how both frameworks handle a realistic scenario: building an API that needs different repository implementations based on context.

Laravel Way

AppServiceProvider.php

public function register()
{
    // Base binding
    $this->app->bind(
        UserRepositoryInterface::class,
        UserRepository::class
    );

    // Need cached version? Create another binding
    $this->app->singleton(
        CachedUserRepositoryInterface::class,
        CachedUserRepository::class
    );
}
Enter fullscreen mode Exit fullscreen mode

UserController.php

public function index(UserRepositoryInterface $repository)
{
    // Using the base repository
}

public function dashboard(CachedUserRepositoryInterface $repository)
{
    // Had to create a new interface just for caching
    // Not ideal
}
Enter fullscreen mode Exit fullscreen mode

Doppar Way

UserController.php

#[Route('/users')]
public function index(
    #[Bind(UserRepository::class)] UserRepositoryInterface $repository
) {
    // Base repository, clearly defined
}

#[Route('/dashboard')]
public function dashboard(
    #[Bind(CachedUserRepository::class)] UserRepositoryInterface $repository
) {
    // Cached repository, same interface, zero extra interfaces needed
}
Enter fullscreen mode Exit fullscreen mode

Everything you need to know is right there in the controller. No context switching. No extra interfaces. Just clean, explicit dependency injection.

Singleton Support: Both Win Here

Both frameworks handle singletons elegantly, but Doppar brings it closer to usage:

Laravel:

$this->app->singleton(PaymentService::class);
Enter fullscreen mode Exit fullscreen mode

Doppar:

#[Bind(PaymentService::class, singleton: true)] PaymentServiceInterface $service
Enter fullscreen mode Exit fullscreen mode

Doppar's approach makes the singleton nature visible at the injection point. You're not left wondering if something is a singleton—it's right there in the attribute.

Constructor Injection: Doppar's Hidden Gem

Here's something really elegant. Doppar lets you use attributes in constructors:

class PostController extends Controller
{
    public function __construct(
        #[Bind(PostRepository::class)] private PostRepositoryInterface $postRepository,
        #[Bind(CacheService::class, true)] private CacheInterface $cache
    ) {}

    #[Route('/posts')]
    public function index()
    {
        // $this->postRepository is ready to go
        // $this->cache is a singleton
    }
}
Enter fullscreen mode Exit fullscreen mode

This is dependency injection that truly stays out of your way. The bindings are declarative, visible, and require zero setup in service providers.

When Would You Choose Laravel?

Let's be fair—Laravel isn't losing here, it's just different. There are scenarios where Laravel's approach makes more sense:

1. Global Application-Wide Bindings
If you need the same binding everywhere in your application, Laravel's centralized service providers are cleaner:

// One place, affects everything
$this->app->singleton(LoggerInterface::class, FileLogger::class);
Enter fullscreen mode Exit fullscreen mode

2. Complex Binding Logic
When your binding needs conditional logic or multiple dependencies:

$this->app->bind(PaymentProcessor::class, function ($app) {
    $config = $app->make('config');

    if ($config->get('payment.provider') === 'stripe') {
        return new StripeProcessor($app->make(StripeClient::class));
    }

    return new PayPalProcessor($app->make(PayPalClient::class));
});
Enter fullscreen mode Exit fullscreen mode

This kind of complex conditional binding is harder to express in attributes.

3. Package Development
If you're building packages that provide services to other applications, Laravel's service provider approach is more conventional and expected.

When Doppar Dominates

Doppar shines in scenarios that represent most of modern application development:

1. Rapid Feature Development
When you're building features quickly and need to see exactly what's being injected:

#[Route('/reports/generate')]
public function generate(
    #[Bind(PDFGenerator::class)] ReportGenerator $generator,
    #[Bind(S3Storage::class)] StorageInterface $storage
) {
    // Everything needed is visible right here
}
Enter fullscreen mode Exit fullscreen mode

2. Contextual Variations
When different endpoints need different implementations:

#[Route('/api/v1/users')]
#[Resolver(UserRepositoryInterface::class, RestUserRepository::class)]
public function v1() { }

#[Route('/api/v2/users')]
#[Resolver(UserRepositoryInterface::class, GraphQLUserRepository::class)]
public function v2() { }
Enter fullscreen mode Exit fullscreen mode

3. Team Collaboration
When multiple developers are working on the same codebase. With Doppar, they don't need to coordinate service provider edits—bindings are localized.

4. Testing and Mocking
While both frameworks support testing, Doppar's explicit bindings make it easier to understand what needs mocking:

// You know exactly what this method depends on
#[Bind(EmailService::class)] EmailServiceInterface $email
Enter fullscreen mode Exit fullscreen mode

Performance: A Tie

Both containers are highly optimized. Laravel has years of performance tuning. Doppar's attribute parsing happens during bootstrap and is cached. In practice, you won't notice a difference.

Developer Experience: Doppar Edges Ahead

Here's where Doppar really wins—the daily development experience.

Less Context Switching
You're not constantly jumping between controllers and service providers. Everything you need is visible in your IDE.

Better IDE Support
Modern IDEs understand attributes well. You get autocomplete, refactoring support, and "go to definition" working perfectly.

Clearer Code Reviews
Reviewers can see exactly what's being injected without opening multiple files. Code review comments like "why is this using CachedRepository?" are answered immediately by looking at the attribute.

Easier Debugging
When something goes wrong, you're not hunting through service providers trying to figure out which concrete class is actually being injected.

The Future is Attribute-Based

Doppar isn't just different—it's showing us where PHP frameworks are heading. Attributes give us the power to make our code more declarative and self-documenting. Why hide your dependency configuration in separate files when you can declare it right where it matters?

Laravel proved that dependency injection could be elegant. Doppar is proving it can be even better—more explicit, more localized, and more aligned with how we actually think about code.

The winner? Developers who value clarity and modern PHP practices. Doppar is here to stay, and it's raising the bar for what we should expect from our dependency injection containers.


What's your take? Have you tried attribute-based dependency injection? Let me know in the comments—I'd love to hear about your experiences with both approaches.

Top comments (0)