In ICanUp, every ContentBlock stores its data in data and has a separate search_text projection for search. Whenever a block is saved, that projection must match the current data.
At first this looks like one task. In practice it contains two different responsibilities: when to update search_text and how to decide which text from an arbitrary block structure should actually be indexed.
Single Responsibility Principle
Single Responsibility Principle: A module should have one, and only one, reason to change.
In simpler terms: if two parts of the code change for different reasons, one class should not be responsible for both.
Two Different Reasons to Change
For search_text, the two reasons are easy to separate:
- Block lifecycle. If the synchronization moment or save lifecycle changes, the Observer changes.
- Search projection rules. If HTML handling changes, new technical fields must be excluded, or new visible values must be indexed, the Extractor changes.
Those changes do not have to happen together, so in ICanUp they do not live in the same class.
The Observer Owns Only the Synchronization Moment
final readonly class ContentBlockSearchTextObserver{ public function __construct( private ContentBlockSearchTextExtractor $extractor, ) {} public function saving(ContentBlock $contentBlock): void { $contentBlock->search_text = $this->extractor->extract( is_array($contentBlock->data) ? $contentBlock->data : [], ); }}ContentBlockSearchTextObserver knows very little about the search text itself. It reacts to saving, passes data to the Extractor, and stores the result in search_text.
It contains no lists of technical fields, HTML rules, URL handling, media metadata rules, or whitespace normalization. Those are not its responsibility.
The Extractor Owns the Search Text Rules
public function extract(array $data): string{ $parts = []; $this->collect($data, $parts); return implode("\n", $parts);} private function collect( mixed $value, array &$parts, ?string $key = null,): void { $normalizedKey = $key !== null ? $this->normalizeKey($key)ContentBlockSearchTextExtractor has a different job. It walks through the data structure recursively, keeps visible text, and ignores technical values.
The real implementation explicitly excludes configuration, settings, IDs, URLs, alt, and other metadata. HTML is converted to plain text, while code that contains HTML syntax remains code.
If these rules change tomorrow, the Observer does not need to be rewritten.
Why This Is Single Responsibility Principle
The important part is not the size of the classes. It is their reasons to change.
ContentBlockSearchTextObserverchanges when the synchronization lifecycle changes.ContentBlockSearchTextExtractorchanges when the rules for searchable content change.
One component answers "when?", the other answers "how?". That boundary makes each responsibility clear.
SRP Does Not Mean "One Method per Class"
ContentBlockSearchTextExtractor has more than one method. Internally it has separate steps for traversing the structure, checking keys, handling HTML, and normalizing whitespace.
That does not violate Single Responsibility Principle because all of those methods serve one goal: build correct searchable text from ContentBlock data.
SRP should not be reduced to the number of lines, methods, or dependencies. The simpler question is: how many independent reasons could force this class to change?
Verifying the Behavior
it('updates search text whenever a ContentBlock is saved', function (): void { $block = ContentBlock::query()->create([ 'blockable_type' => 'post', 'blockable_id' => 9001, 'locale' => 'uk', 'type' => ContentBlockTypeEnum::PARAGRAPH->value, 'position' => 1, 'data' => [ 'text' => 'Перший searchable текст', ], 'is_active' => true, ]); expect($block->search_text) ->toBe('Перший searchable текст'); The feature test verifies the lifecycle separately: after creation and after another save(), search_text matches the current data.
The extraction rules are covered by separate unit tests: visible text is indexed, technical metadata is skipped, HTML becomes plain text, and code content is preserved.
The tests follow the same responsibility boundary as the production code.
Practical Takeaway
My simplest practical interpretation of Single Responsibility Principle is: do not put things that change for different reasons into the same class.
In this case, the Observer knows when to synchronize search_text. The Extractor knows how to build it. Both classes stay small, but that is a result of a good boundary, not the goal of SRP itself.



