Design patterns are like recipes for solving common programming problems. After 10 years of Laravel development, I've seen countless projects where understanding design patterns made the difference between clean, maintainable code and a tangled mess that nobody wants to touch.
When I first moved to San Francisco and started working with larger development teams, I quickly realized that speaking the language of design patterns wasn't just about showing off – it was about effective communication. When a senior developer says "let's use a Factory here," everyone instantly understands the approach.
The Gang of Four Foundation
The classic Gang of Four patterns remain surprisingly relevant in modern PHP development. While frameworks like Laravel implement many patterns under the hood, understanding them helps you write better code and make informed architectural decisions.
1. Factory Pattern - Object Creation Made Simple
The Factory pattern is everywhere in Laravel. Think of how Eloquent models create instances or how the container resolves dependencies. Here's a practical example:
interface PaymentGatewayInterface
{
public function charge(float $amount): bool;
}
class StripeGateway implements PaymentGatewayInterface
{
public function charge(float $amount): bool
{
// Stripe API implementation
return true;
}
}
class PayPalGateway implements PaymentGatewayInterface
{
public function charge(float $amount): bool
{
// PayPal API implementation
return true;
}
}
class PaymentGatewayFactory
{
public static function create(string $type): PaymentGatewayInterface
{
return match($type) {
'stripe' => new StripeGateway(),
'paypal' => new PayPalGateway(),
default => throw new InvalidArgumentException("Unknown gateway: $type")
};
}
}
// Usage
$gateway = PaymentGatewayFactory::create('stripe');
$gateway->charge(99.99);
I've used this pattern countless times when building e-commerce applications. It makes adding new payment providers trivial and keeps the client code clean.
2. Strategy Pattern - Flexible Algorithms
The Strategy pattern is perfect for situations where you need different algorithms for the same task. In my experience, it's particularly useful for pricing strategies, validation rules, or notification methods:
interface PricingStrategyInterface
{
public function calculate(float $basePrice, User $user): float;
}
class RegularPricing implements PricingStrategyInterface
{
public function calculate(float $basePrice, User $user): float
{
return $basePrice;
}
}
class StudentDiscountPricing implements PricingStrategyInterface
{
public function calculate(float $basePrice, User $user): float
{
return $basePrice * 0.8; // 20% discount
}
}
class VIPPricing implements PricingStrategyInterface
{
public function calculate(float $basePrice, User $user): float
{
return $basePrice * 0.7; // 30% discount
}
}
class PricingCalculator
{
private PricingStrategyInterface $strategy;
public function __construct(PricingStrategyInterface $strategy)
{
$this->strategy = $strategy;
}
public function calculatePrice(float $basePrice, User $user): float
{
return $this->strategy->calculate($basePrice, $user);
}
}
// Usage
$calculator = new PricingCalculator(new StudentDiscountPricing());
$finalPrice = $calculator->calculatePrice(100.0, $user);
This pattern has saved me countless hours when business requirements change. Instead of modifying existing code, I just add new strategy implementations.
3. Observer Pattern - Decoupled Event Handling
Laravel's event system is essentially an implementation of the Observer pattern. But sometimes you need a simpler, more direct approach:
interface ObserverInterface
{
public function update(string $event, mixed $data): void;
}
class EmailNotificationObserver implements ObserverInterface
{
public function update(string $event, mixed $data): void
{
if ($event === 'user_registered') {
// Send welcome email
Mail::to($data['email'])->send(new WelcomeEmail($data));
}
}
}
class AnalyticsObserver implements ObserverInterface
{
public function update(string $event, mixed $data): void
{
if ($event === 'user_registered') {
// Track analytics event
Analytics::track('user_registered', $data);
}
}
}
class UserRegistrationSubject
{
private array $observers = [];
public function addObserver(ObserverInterface $observer): void
{
$this->observers[] = $observer;
}
public function notify(string $event, mixed $data): void
{
foreach ($this->observers as $observer) {
$observer->update($event, $data);
}
}
public function registerUser(array $userData): void
{
// Registration logic
$user = User::create($userData);
// Notify observers
$this->notify('user_registered', $user->toArray());
}
}
4. Decorator Pattern - Adding Functionality Dynamically
The Decorator pattern is excellent for adding features to objects without modifying their structure. I've used it for logging, caching, and validation:
interface CacheInterface
{
public function get(string $key): mixed;
public function set(string $key, mixed $value, int $ttl = 3600): bool;
}
class RedisCache implements CacheInterface
{
private Redis $redis;
public function __construct(Redis $redis)
{
$this->redis = $redis;
}
public function get(string $key): mixed
{
return $this->redis->get($key);
}
public function set(string $key, mixed $value, int $ttl = 3600): bool
{
return $this->redis->setex($key, $ttl, serialize($value));
}
}
class LoggingCacheDecorator implements CacheInterface
{
private CacheInterface $cache;
private LoggerInterface $logger;
public function __construct(CacheInterface $cache, LoggerInterface $logger)
{
$this->cache = $cache;
$this->logger = $logger;
}
public function get(string $key): mixed
{
$this->logger->info("Cache GET: $key");
return $this->cache->get($key);
}
public function set(string $key, mixed $value, int $ttl = 3600): bool
{
$this->logger->info("Cache SET: $key");
return $this->cache->set($key, $value, $ttl);
}
}
// Usage
$cache = new LoggingCacheDecorator(
new RedisCache(new Redis()),
new Logger()
);
5. Command Pattern - Encapsulating Actions
The Command pattern is perfect for implementing undo/redo functionality, queuing operations, or creating flexible action systems:
interface CommandInterface
{
public function execute(): void;
public function undo(): void;
}
class CreateUserCommand implements CommandInterface
{
private array $userData;
private ?User $createdUser = null;
public function __construct(array $userData)
{
$this->userData = $userData;
}
public function execute(): void
{
$this->createdUser = User::create($this->userData);
}
public function undo(): void
{
if ($this->createdUser) {
$this->createdUser->delete();
$this->createdUser = null;
}
}
}
class CommandInvoker
{
private array $history = [];
public function execute(CommandInterface $command): void
{
$command->execute();
$this->history[] = $command;
}
public function undo(): void
{
if (!empty($this->history)) {
$command = array_pop($this->history);
$command->undo();
}
}
}
Modern PHP Patterns
PHP 8.x introduced features that make implementing patterns more elegant:
// Using enums for type safety
enum PaymentStatus: string
{
case PENDING = 'pending';
case COMPLETED = 'completed';
case FAILED = 'failed';
}
// Using attributes for metadata
#[Attribute]
class Cacheable
{
public function __construct(public int $ttl = 3600) {}
}
class ProductService
{
#[Cacheable(ttl: 1800)]
public function getPopularProducts(): array
{
// Implementation
}
}
Testing Design Patterns
One of the biggest advantages of design patterns is testability. Here's how to test the Strategy pattern:
use PHPUnit\Framework\TestCase;
class PricingCalculatorTest extends TestCase
{
public function test_regular_pricing_returns_base_price(): void
{
$strategy = new RegularPricing();
$calculator = new PricingCalculator($strategy);
$user = new User();
$result = $calculator->calculatePrice(100.0, $user);
$this->assertEquals(100.0, $result);
}
public function test_student_discount_applies_correctly(): void
{
$strategy = new StudentDiscountPricing();
$calculator = new PricingCalculator($strategy);
$user = new User();
$result = $calculator->calculatePrice(100.0, $user);
$this->assertEquals(80.0, $result);
}
}
When NOT to Use Design Patterns
This is crucial – design patterns aren't always the answer. I've seen developers over-engineer simple problems with unnecessary patterns. Use them when:
- You have a genuine need for flexibility
- The complexity is justified by the benefits
- Your team understands the pattern
- You're solving a recurring problem
Don't use patterns just because they're "professional" – sometimes a simple function is the best solution.
Conclusion
Design patterns are tools, not rules. They provide a common vocabulary and proven solutions to recurring problems. In my decade of PHP development, the patterns I've shared here have consistently made my code more maintainable and my teams more effective.
Start with the basics – Factory, Strategy, and Observer patterns will solve 80% of your design challenges. As you become more comfortable, experiment with Decorator and Command patterns for more complex scenarios.
Remember, the best pattern is the one that makes your code easier to understand, test, and maintain. Don't let pattern perfection become the enemy of working software.
The most successful projects I've worked on in San Francisco had one thing in common: they used design patterns judiciously, applying them where they added real value rather than complexity. That's the mindset that will serve you well in your PHP journey.
Add Comment
No comments yet. Be the first to comment!