Public search in ICanUp works with Posts, Categories, News, and Tips. Every type has its own query, relations, and public-visibility rules.
If all of that logic lived in one central service, every new content type would add another condition to code that already works.
Instead, the shared search flow works through one interface while the varying behavior lives in separate providers.
Open/Closed Principle
Open/Closed Principle: Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
In simpler terms: a system should be easy to extend with new behavior without repeatedly rewriting stable code that already does its job.
One Contract for Different Search Types
interface SearchProviderInterface{ public function type(): SearchTypeEnum; public function count(string $query): int; public function search( string $query, int $perPage = 12, ): LengthAwarePaginator;}SearchProviderInterface defines only what the shared flow needs: the result type, the match count, and paginated search.
PostSearchProvider, CategorySearchProvider, NewsSearchProvider, and TipSearchProvider implement that contract while each provider owns its own query.
SearchPage Stays Stable
public function __construct( private readonly string $query, private readonly SearchProviderInterface $activeProvider, private readonly array $providers,) {} public function results(): ?LengthAwarePaginator{ if ($this->query === '') { return null; } if (! $this->resultsResolved) { $this->results = $this->activeProvider->search( $this->query,SearchPage does not check whether it received a Post, News, or Tip provider. It calls search(), count(), and type() through the shared interface.
The differences between concrete queries therefore do not grow inside the central class. The stable flow stays the same while new behavior is added separately.
An Extension Still Needs Registration
$this->app->tag([ PostSearchProvider::class, CategorySearchProvider::class, NewsSearchProvider::class, TipSearchProvider::class,], self::PUBLIC_SEARCH_PROVIDERS_TAG); $this->app->bind( SearchService::class, function ($app) { return new SearchService( $app->tagged( self::PUBLIC_SEARCH_PROVIDERS_TAG, ), ); },Open/Closed Principle does not mean that old files can never be changed again.
In ICanUp, a provider still needs registration in the composition root, and a new search type may require an update to SearchTypeEnum. Those are normal explicit extension points.
The important part is that the provider does not force its query rules into SearchPage as another if or switch.
A Second Example - Analytics Caching Through a Decorator
final readonly class CachedAnalyticsAggregationRepository implements AnalyticsAggregationRepositoryInterface{ public function __construct( private AnalyticsAggregationRepositoryInterface $source, private AnalyticsAggregationCache $cache, ) {} public function aggregate( AnalyticsAggregationQuery $query, ): iterable { return $this->cache->remember( $query, fn (): iterable => $this->source->aggregate($query), );MySqlAnalyticsAggregationRepository already knows how to read aggregates from the database. When caching became necessary, that implementation was not turned into a repository plus cache manager.
CachedAnalyticsAggregationRepository implements the same contract, wraps the source repository, and adds caching through composition.
This is another practical OCP case: new behavior is added around a stable implementation rather than inside it.
What Open/Closed Principle Does Not Mean
- There is no need to create an interface for every class in advance.
- There is no need to predict every future extension point.
- Existing code is not forbidden to change.
- Using Provider, Strategy, or Decorator by itself does not prove OCP.
My practical question is simple: when a new behavior appears, can I add it next to the existing implementations without turning stable code into an ever-growing switch?
Practical Takeaway
In this case, Open/Closed Principle is visible at the boundary between a stable mechanism and behavior that varies.
In Search, SearchPage stays stable while concrete queries grow through providers. In Analytics, caching is added by a Decorator around the repository.
My simple takeaway is that a new behavior is better added as a new extension than as one more condition inside code that already works.



