0% прочитано

Registry Pattern in Laravel: Adding Content Blocks Without a Giant Switch

The ICanUp Page Builder grew from simple Paragraph and Image blocks to Hero, Cards, FAQ, Forms, Model Collection, and scoped blocks. This article shows how the Registry Pattern collects definitions behind one contract, resolves the right implementation by type, and lets the system grow without one giant switch.

27 вересня 2026 р. 10 хв читанняLaravel

The Registry Pattern appeared in ICanUp not because I wanted to add another design pattern to the codebase.

The reason was practical: the Page Builder kept growing. It started with a few simple Content Blocks and later gained Hero, CTA, Highlight, Cards, Steps, FAQ, Model Collection, Forms, and specialized blocks.

If every new type added another switch to validation, editor options, defaults, and domain services, one central class would quickly become the place that had to change for every new block.

A Registry does not remove every conditional from the system. Its job is different: give one type a stable path to the correct implementation without forcing every consumer to know every concrete class.

The Problem Is Not the Switch Itself but the Number of Places That Change

A single match or switch is not automatically a problem. The problem starts when knowledge about every block type is duplicated across the system.

A new Content Block needs a type, label, default data, validation rules, localized validation attributes, and for presentation blocks, variants and options. Some blocks are also valid only for specific blockable models.

If all of those decisions live in one central switch, adding a block means editing a large structure that already knows about Paragraph, Image, Code, Hero, FAQ, Forms, and everything added later.

Without a Registry

With a Registry

The consumer knows every concrete block class.

The consumer asks for a definition by type.

A new type changes a central conditional.

A new type adds its own definition.

Rules and defaults can drift between layers.

Rules and defaults live behind the definition contract.

Scope support is easy to duplicate.

The Registry centralizes support checks.

First You Need a Stable Definition Contract

PHP - One contract for Content Block definitions
interface ContentBlockDefinitionInterface{    public function type(): ContentBlockTypeEnum;     public function label(): string;     public function defaultData(): array;     public function rules(): array;     public function attributes(): array;}

A Registry only becomes useful when registered objects share a stable contract. In this case each definition owns its type and the behavior directly associated with that type.

For example, the Code block owns rules for language, code, title, line numbers, and highlighted lines. A Notice block has a completely different payload. The Registry does not need to know those details.

ContentBlockRegistry Knows Implementations but Not Their Internal Logic

PHP - Resolve a definition by block type
final readonly class ContentBlockRegistry{    /**     * @param iterable<ContentBlockDefinitionInterface> $definitions     */    public function __construct(        private iterable $definitions,    ) {}     public function all(): array    {        return $this->serializeDefinitions(            $this->definitions,        );    } 

The Registry does not create blocks and does not validate requests by itself. It owns a collection of implementations and answers one question: which definition belongs to this type?

That is a small responsibility, but it removes type-to-class knowledge from consumers.

The Service Provider Builds the Registry in One Composition Root

PHP - Register block definitions in the application container
final class ContentBlockServiceProvider    extends ServiceProvider{    private const array DEFINITIONS = [        ButtonBlockDefinition::class,        CodeBlockDefinition::class,        DividerBlockDefinition::class,        GalleryBlockDefinition::class,        HeadingBlockDefinition::class,        ImageBlockDefinition::class,        RichTextBlockDefinition::class,        BeforeAfterBlockDefinition::class,        NoticeBlockDefinition::class,        HeroBlockDefinition::class,        CtaBlockDefinition::class,        HighlightBlockDefinition::class,

Concrete classes still need to be listed somewhere. The Registry Pattern does not make them magically discoverable.

In ICanUp, that place is the Service Provider. It acts as the module composition root: the Laravel container creates concrete definitions and passes them into the Registry.

The important difference is that implementation knowledge lives in one infrastructure location instead of being copied into every service or request.

Validation Asks the Resolved Definition for Its Rules

PHP - Resolve block-specific validation
private function resolvedBlockDefinitions(): array{    $definitions = [];    $blocks = $this->input(        'blocks',        [],    );     foreach (        $blocks        as $index => $block    ) {        $type =            $block['type']            ?? null; 

The request layer does not need to know that code maps to CodeBlockDefinition or that faq maps to FaqBlockDefinition.

It reads the type from the payload, asks the Registry for the definition, and then uses its rules() and attributes().

Validation orchestration stays shared while block-specific rules remain next to the block contract itself.

The Registry Grew: Not Every Block Belongs to Every Model

PHP - Filter definitions by blockable type
public function allFor(    string $blockableType,): array {    return $this->serializeDefinitions(        collect($this->definitions)            ->filter(                fn (                    ContentBlockDefinitionInterface $definition                ): bool =>                    $this->supportsDefinition(                        $definition,                        $blockableType,                    )            ),    );}

Later, a simple lookup was no longer enough. Some Content Blocks only make sense for specific models.

I did not want Page, Post, News, and other consumers to maintain their own lists of allowed types. The Registry therefore gained allFor() and supports().

Global definitions remain available everywhere. A scoped definition answers whether it supports a particular blockable type.

The Same Registry Can Expose Metadata for the Editor

PHP - Serialize definitions for the editor
private function serializeDefinitions(    iterable $definitions,): array {    return collect($definitions)        ->map(            static fn (                ContentBlockDefinitionInterface $definition            ) => [                'type' =>                    $definition->type()->value,                 'label' =>                    $definition->label(),                 'default_data' =>                    $definition->defaultData(),

The Registry also became a boundary between domain definitions and the Admin editor.

The editor can receive a type, localized label, default data, and presentation metadata without a second hardcoded backend map.

The Registry does not force every definition to have presentation settings. It adds variants and options only for implementations that support the corresponding interface.

What Changes When a New Block Is Added

  1. Add a new value to ContentBlockTypeEnum.
  2. Create a definition implementing the shared contract.
  3. Define its default data, validation rules, and attributes.
  4. Implement presentation or scoped interfaces when needed.
  5. Register the definition in the composition root.
  6. Add the frontend editor and public renderer for the new type.

Backend consumers already using the Registry do not need another case for every new type.

Open/Closed does not mean "never edit an existing file". The Service Provider still receives a new registration. The important part is that consumers do not need to be rewritten for every concrete type.

Registry Is Not the Same as a Factory

This distinction is worth keeping explicit, especially in a design-pattern series.

Registry stores or exposes a known set of implementations by key.

Factory is responsible for creating or selecting the object returned to the caller.

In Content Blocks, the Registry returns a definition by type. A later article in this series will use the Communication module for a separate Factory example.

What the Registry Does Not Solve

Problem

Still Needs Separate Handling

Duplicate types

A test or registration guard.

Frontend renderer

A separate frontend registry/map.

Unknown type

An explicit exception or validation error.

Block-specific business logic

It remains inside the definition or service.

Registration order

It should remain deterministic.

When a Registry Would Be Overkill

If the system had two stable block types that never changed, a Registry would add more structure than value.

It became justified in ICanUp because the scale actually changed: many definitions, different validation contracts, presentation metadata, scoped support, and multiple consumers of the same type system.

The pattern is useful not because there are many classes, but because the system repeatedly needs to resolve an implementation by a stable key.

The Registry Pattern became useful when the question "which type is this?" started repeating across different parts of the system.

Conclusion

The real Registry Pattern in ICanUp is small: an iterable of definitions, get(), serialization, and support checks.

That small abstraction allowed the Page Builder to grow without a central switch that understood the internal structure of every Content Block.

Each definition owns its contract. The Registry knows how to find it. Consumers know only the Registry and the interface.

That is what makes a design pattern useful to me: not an extra layer for its own sake, but a stable extension point that reduces the number of places that must change together.