Ray.Di Fundamentals: Google Guice’s Ideas in PHP
Why Ray.Di
People often say that “DI containers are complex to configure and expensive to learn.” Indeed, many PHP DI frameworks have elements that trouble developers: configuration in arrays or YAML, string-based service definitions, and errors that only surface at runtime.
Ray.Di brings Google Guice’s design philosophy to PHP and solves these problems at the root. Type safety, explicit configuration, and optional compile-time optimization make it a DI framework you can use with confidence even in large applications.
Fundamental differences from other DI frameworks
Problems with the Service Locator pattern
Many PHP DI containers are actually implementations of the Service Locator pattern. They describe configuration in arrays or closures and retrieve services by string key:
// Conventional DI container (such as Pimple)
$container['database'] = function($c) {
return new PDO($c['db.dsn'], $c['db.user'], $c['db.pass']);
};
$container['user.repository'] = function($c) {
return new UserRepository($c['database']); // Access by string
};
// Problem: a typo in 'database' is not detected until runtime
// Problem: no IDE support
// Problem: refactoring is difficult
This approach has fundamental problems.
First, hidden dependencies. Looking at a class’s constructor does not tell you which dependencies it needs. You cannot tell that UserRepository depends on the 'database' service without reading the implementation code. This significantly reduces the readability and maintainability of the code.
Second, runtime errors. If you mistype 'database' as 'datbase', neither PHP’s type system nor your IDE can detect it; the error only becomes apparent when that code actually runs. This greatly undermines application stability and risks sudden errors in production.
Third, lack of IDE support. Because identifiers are strings, IDE auto-completion and refactoring features do not work at all. Renaming a class does not automatically update the strings in the DI configuration, so you must find and fix them by hand.
Fourth, testing difficulties. Because dependencies are hidden, injecting mock objects during tests becomes difficult. Since the constructor does not make clear what is needed, preparing tests becomes complicated.
Ray.Di’s declarative approach
Ray.Di adopts the ideas of Google Guice, which has a proven track record in the Java world. You declare dependencies as rules, and the framework builds the dependency graph automatically:
use Ray\Di\AbstractModule;
use Ray\Di\Scope;
// Ray.Di's declarative configuration
class AppModule extends AbstractModule
{
protected function configure(): void
{
// Declare the relationship between interface and implementation
$this->bind(DatabaseInterface::class)
->to(MySQLDatabase::class)
->in(Scope::SINGLETON);
$this->bind(UserRepositoryInterface::class)
->to(UserRepository::class);
}
}
// Type-safe: full IDE support, refactoring-friendly
// Explicit: what is bound to what is immediately clear
// Verifiable: configuration mistakes are detected immediately
This difference is not merely a matter of syntax. In Ray.Di, dependencies are expressed as types, so you can make full use of PHP’s type system and IDE features. Configuration mistakes are detected when the DI container resolves the bindings—at container build time with the compiled injector, or on first resolution with the runtime injector—rather than surfacing later in production.
Optional compilation
An even more important difference is that Ray.Di can compile its configuration into optimized PHP code. Whereas other DI containers resolve dependencies at runtime, the compiled injector resolves all dependencies in advance and generates optimal instance-creation code:
// Optimized code generated by the compiler (conceptual example)
class UserService_RayAop
{
public function __construct()
{
$this->db = new MySQLDatabase('localhost', 'user', 'pass');
$this->repository = new UserRepository($this->db);
$this->logger = new FileLogger('/var/log/app.log');
}
}
Compiling is an optional step. The plain Injector shown below resolves bindings at runtime; the compiled path needs the ray/compiler package: DiCompiler writes the generated factory code to a cache directory, and ScriptInjector loads it. With it, the application runs as fast as if the dependencies had been wired by hand. See Performance boost for the setup.
Basic concepts: the first step
To understand Ray.Di, let’s start with a simple example. Installation is the same as for any other Composer package:
composer require ray/di
The basic pattern of dependency injection
Consider a simple application that generates greeting messages. It needs to return a different greeting depending on the language:
<?php
use Ray\Di\Injector;
use Ray\Di\AbstractModule;
interface GreetingServiceInterface
{
public function greet(string $name): string;
}
class EnglishGreetingService implements GreetingServiceInterface
{
public function greet(string $name): string
{
return "Hello, {$name}!";
}
}
class HelloWorld
{
public function __construct(
private GreetingServiceInterface $greetingService
) {}
public function sayHello(string $name): string
{
return $this->greetingService->greet($name);
}
}
What matters here is that the HelloWorld class does not depend directly on EnglishGreetingService. By depending on an interface, the implementation can be swapped freely.
Configuration with modules
Ray.Di is configured in units called “modules.” A module is the place where relationships between interfaces and implementations are defined:
$injector = new Injector(new class extends AbstractModule {
protected function configure(): void
{
$this->bind(GreetingServiceInterface::class)->to(EnglishGreetingService::class);
}
});
$helloWorld = $injector->getInstance(HelloWorld::class);
echo $helloWorld->sayHello('World'); // "Hello, World!"
This configuration establishes the rule “when GreetingServiceInterface is requested, provide EnglishGreetingService.” If you want to switch to a Japanese greeting, you only change the module configuration. The business logic is not touched at all.
Module design in practice
In a real application, configuration grows. Ray.Di’s module system provides a powerful mechanism for managing this complexity.
Modules can be split by concern. By putting database-related configuration, email configuration, and business logic configuration into separate modules, each module takes on a single responsibility:
class DatabaseModule extends AbstractModule
{
protected function configure(): void
{
$this->bind(DatabaseConnectionInterface::class)
->to(MySQLConnection::class)
->in(Scope::SINGLETON);
$this->bind(UserRepositoryInterface::class)
->to(MySQLUserRepository::class);
}
}
class AppModule extends AbstractModule
{
protected function configure(): void
{
$this->install(new DatabaseModule());
$this->install(new EmailModule());
$this->bind(OrderServiceInterface::class)->to(OrderService::class);
}
}
The install() method lets you combine modules. This is not merely splitting configuration. Each module becomes independently testable and reusable across different projects.
Using binding patterns
Ray.Di provides various binding patterns, each solving a specific problem.
The most basic pattern is binding an interface to an implementation. Real applications, however, have more complex needs. When you want to inject a configuration value, use instance binding. When object creation requires complex logic, use a provider.
use Ray\Di\AbstractModule;
class AppModule extends AbstractModule
{
protected function configure(): void
{
// Instance binding for a configuration value
$config = new AppConfig(['database' => 'mysql://localhost/myapp']);
$this->bind(AppConfig::class)->toInstance($config);
// Provider for cases that need complex initialization
$this->bind(DatabaseConnectionInterface::class)
->toProvider(DatabaseConnectionProvider::class);
}
}
Especially powerful is annotated binding. When you need multiple implementations of the same interface, you can distinguish them with the #[Named] attribute:
use Ray\Di\Di\Named;
class OrderService
{
public function __construct(
#[Named('file')] private LoggerInterface $fileLogger,
#[Named('email')] private LoggerInterface $emailLogger
) {}
}
This feature lets you clearly distinguish loggers with different purposes, such as “file log” and “email log.” Unlike a string-based service locator, type safety is preserved.
Choosing an injection style
Ray.Di mainly recommends constructor injection. There are clear reasons for this. Constructor injection guarantees that all dependencies are satisfied at the moment the object is created. An object in an incomplete state never exists.
class OrderService
{
public function __construct(
private UserRepositoryInterface $userRepository,
private PaymentServiceInterface $paymentService
) {}
}
Method injection can be used for optional dependencies or to resolve circular dependencies. Ray.Di, however, deliberately does not support property injection. This is because property injection breaks encapsulation and allows objects to exist in an incomplete state.
Object lifecycle management
When objects are created and how long they are retained significantly affects application performance and memory usage. Ray.Di provides two scopes.
In the default prototype scope, a new instance is created every time a dependency is requested. This suits stateless services and objects that need state that differs per request.
Singleton scope, on the other hand, shares a single instance across the whole application:
$this->bind(DatabaseConnectionInterface::class)
->to(MySQLConnection::class)
->in(Scope::SINGLETON);
Singleton scope suits objects that are expensive to create and safe to share state with, such as database connections and loggers.
Important warning: Singletons must be used with care. Singletons create global state, which can cause the following problems.
First, difficulty in testing. Global state causes state to leak between tests and undermines test independence. The state of a singleton modified in one test affects the next test. Second, hidden dependencies. Singletons create implicit dependencies and conceal a class’s true dependencies. Looking at the constructor no longer tells you what the class depends on. Third, concurrency problems. Sharing state across multiple requests can cause race conditions and unexpected data corruption.
What singletons are good for:
Singletons are suitable for stateless services. Services without internal state, such as validators, calculators, and converters, are ideal for singletons. Singletons are also suitable for read-only configuration loaded at application startup, and for cases where resources must be managed efficiently, such as a database connection pool.
What to avoid making singleton:
Conversely, mutable objects, such as shopping carts, user sessions, and request-specific data, should never be singletons. The same applies to request-scoped data tied to an HTTP request. Making these singletons causes serious security problems by mixing data between users.
Switching configuration per environment
Real applications need different configuration in development and production. Use an in-memory cache in development and Redis in production. Do not actually send email in development, but do send it in production. Ray.Di solves these requirements elegantly.
Simply create a different module for each environment and select the appropriate one at startup:
$module = getenv('APP_ENV') === 'production'
? new ProductionModule()
: new DevelopmentModule();
$injector = new Injector($module);
This approach prevents environment-specific configuration from being scattered throughout the code. Every environmental difference is expressed explicitly as a module.
Testing strategy
One of the greatest benefits of dependency injection is ease of testing. With Ray.Di, you can easily replace complex production dependencies with lightweight test implementations.
class TestModule extends AbstractModule
{
protected function configure(): void
{
$this->bind(UserRepositoryInterface::class)->to(InMemoryUserRepository::class);
$this->bind(EmailServiceInterface::class)->to(MockEmailService::class);
}
}
In tests, you build the injector with this TestModule. No database connection, no external API, no side effects. You can focus purely on testing business logic.
Practical example: putting it all together
Let’s look at a practical example that integrates the concepts covered so far. We will build an order processing service:
use Ray\Di\AbstractModule;
use Ray\Di\Di\Named;
use Ray\Di\Injector;
use Ray\Di\Scope;
class OrderService
{
public function __construct(
private PaymentServiceInterface $paymentService,
private EmailServiceInterface $emailService,
#[Named('audit')] private LoggerInterface $logger
) {}
public function processOrder(Order $order): void
{
$this->logger->info("Processing order: {$order->id}");
$this->paymentService->charge($order->total);
$this->emailService->sendConfirmation($order);
}
}
// Common bindings that do not depend on the environment
class AppModule extends AbstractModule
{
protected function configure(): void
{
$this->bind(EmailServiceInterface::class)->to(SendGridEmailService::class);
$this->bind(LoggerInterface::class)
->annotatedWith('audit')
->to(FileLogger::class)
->in(Scope::SINGLETON);
}
}
// Environment-specific bindings go in dedicated modules
class ProductionModule extends AbstractModule
{
protected function configure(): void
{
$this->install(new AppModule());
$this->bind(PaymentServiceInterface::class)->to(StripePaymentService::class);
}
}
class DevelopmentModule extends AbstractModule
{
protected function configure(): void
{
$this->install(new AppModule());
$this->bind(PaymentServiceInterface::class)->to(MockPaymentService::class);
}
}
// Determine the environment only at the entry point
$module = getenv('APP_ENV') === 'production'
? new ProductionModule()
: new DevelopmentModule();
$orderService = (new Injector($module))->getInstance(OrderService::class);
This example demonstrates Ray.Di’s main features. Switching the payment service is separated into environment-specific modules, and no conditional branching is brought into the modules. Named binding distinguishes the audit logger. Singleton scope shares the logger instance.
Design best practices
Several important principles help you use Ray.Di effectively.
First, always program to an interface. Binding directly to a concrete class makes testing difficult and prevents swapping implementations. Using interfaces frees the consumer from implementation details.
Second, split modules at an appropriate granularity. Rather than putting everything into one huge module, split modules by concern. Putting database configuration, email configuration, and business logic configuration into separate modules gives each module a single responsibility.
Finally, keep dependencies to a minimum. Inject only the dependencies the class needs. Adding unnecessary dependencies “just in case” complicates tests and makes the code harder to understand.
Summary
Ray.Di brings Google Guice’s design philosophy to PHP and realizes type-safe, explicit dependency injection. Unlike a string-based service locator, it gives you full IDE support, and configuration mistakes are detected before execution.
Configuration can optionally be compiled into optimized PHP code with ray/compiler, which brings runtime overhead close to hand-wired code. It provides everything real application development needs: per-environment configuration switching, mock injection for tests, and management of complex initialization logic.
This explicitness and type safety are the keys to guaranteeing maintainability and testability even in large applications.
Next: Factory Pattern
Previous: SOLID Principles in Practice