0% прочитано

Factory Pattern in Laravel: Choosing the Right Conversation Resolver by Thread Type

The ICanUp Communication Center has Private, Group, Support, Feedback, and Post Discussion threads, but each type is resolved differently. This article shows how ThreadResolverFactory selects the correct resolver by ThreadTypeEnum while MessageService stays independent of concrete implementations.

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

The Factory Pattern in ICanUp appeared inside the Communication Center.

At first, creating a conversation thread looks like one operation. In practice, Private chat, Group chat, Support requests, Feedback, and Post Discussion follow very different rules.

A Private thread can reuse a conversation between the same participants. A Group thread creates a different participant structure. Support and Feedback work with guest data and assignees. Post Discussion is attached to a specific Post and should reuse the discussion for the same subject.

I did not want MessageService to understand all of those rules.

In this case the Factory does not create the Thread itself. It selects the object that knows how to resolve the required Thread. This is a small simple factory, not a subclass-based Factory Method.

Without the Factory MessageService Would Know Every Thread Type

PHP - MessageService owns resolver selection
public function create(    CreateMessageData $data,): Message {    $thread = match (        $data->threadContext->type    ) {        ThreadTypeEnum::PRIVATE =>            $this->privateResolver                ->resolve(                    $data->threadContext                ),         ThreadTypeEnum::GROUP =>            $this->groupResolver                ->resolve(                    $data->threadContext

This code would work. But every new Thread type would modify a service whose real responsibility is creating Messages.

The constructor of MessageService would also need every concrete resolver as a separate dependency.

Message creation would become coupled to the complete catalog of conversation types.

First the Resolvers Received One Contract

PHP - One contract for all Thread resolvers
interface ThreadResolverInterface{    public function resolve(        ThreadContextData $context,    ): Thread;}

The Factory is useful because the caller can receive any resolver through one interface.

Every implementation accepts ThreadContextData and returns a Thread, while its internal behavior can be completely different.

The Factory selects the implementation and the caller no longer needs to know its concrete class.

ThreadResolverFactory Has One Small Responsibility

PHP - Core shape of ThreadResolverFactory
final readonly class ThreadResolverFactory{    /**     * @param array<string, ThreadResolverInterface> $resolvers     */    public function __construct(        private array $resolvers,    ) {}     public function make(        ThreadTypeEnum $type,    ): ThreadResolverInterface {        return $this->resolvers[            $type->value        ];    }

The core of the factory is simple: receive a ThreadTypeEnum and return the corresponding ThreadResolverInterface.

A production implementation can also guard against unsupported mappings. The architectural point is not the exception but that type-to-resolver lookup is isolated from MessageService.

The Laravel Container Builds the Mapping in One Place

PHP - Map Thread types to resolver implementations
$this->app->singleton(    ThreadResolverFactory::class,    static fn ($app) =>        new ThreadResolverFactory([            ThreadTypeEnum::PRIVATE->value =>                $app->make(                    PrivateThreadResolver::class                ),             ThreadTypeEnum::GROUP->value =>                $app->make(                    GroupThreadResolver::class                ),             ThreadTypeEnum::SUPPORT->value =>                $app->make(

Concrete classes still need to be known somewhere. In ICanUp, that place is CommunicationServiceProvider.

The container builds a mapping of ThreadTypeEnum value -> resolver object. The domain service receives an already configured factory and does not own composition.

When another resolver is added, concrete wiring changes in one composition root.

MessageService Sees the Factory and Interface, Not the Concrete Resolver

PHP - Factory selection inside message creation
public function create(    CreateMessageData $data,): Message {    return DB::transaction(        function () use ($data): Message {            $resolver =                $this->threadResolverFactory                    ->make(                        $data                            ->threadContext                            ->type                    );             $thread =                $resolver->resolve(                    $data->threadContext

This code shows the value of the factory most clearly.

MessageService knows that it needs a Thread. It gives the type to the factory, receives a resolver, and calls the shared resolve().

It does not need to know whether the Private resolver searches for an existing conversation, the Support resolver adds assignees, or Post Discussion works with a subject.

The Factory Hides Selection Without Erasing Resolver Differences

Resolver

Own Responsibility

PrivateThreadResolver

Handles direct conversations and participants and can reuse an existing private thread.

GroupThreadResolver

Creates group conversations, owner/member roles, and normalizes participants.

SupportThreadResolver

Creates Support threads, supports guest context, and adds users with support reply permission as assignees.

FeedbackThreadResolver

Creates Feedback threads and handles guest data plus feedback assignees.

PostDiscussionThreadResolver

Requires a Post subject, creates a public discussion, and reuses the thread for the same subject.

Post Discussion Shows Why the Resolver Deserves Its Own Object

PHP - Post Discussion has its own creation rules
public function resolve(    ThreadContextData $context,): Thread {    return DB::transaction(        static function () use (            $context,        ): Thread {            $subject =                $context->subject();             if ($subject === null) {                throw new InvalidArgumentException(                    __(                        'validation.communication.'                        .'post_discussion.'                        .'subject_required'

This is no longer simply "create a Thread with a type". The resolver validates the subject, defines discussion identity through type + subject_type + subject_id, uses firstOrCreate(), and applies its own visibility.

If this behavior lived inside a generic factory, the factory would quickly become another large service containing branches for every Thread type.

The factory therefore selects a resolver while domain behavior stays inside that resolver.

ThreadContextData Lets the Factory Stay Small

PHP - One context object for resolver input
final readonly class ThreadContextData{    public function __construct(        public ThreadTypeEnum $type,        public ?Model $subject = null,        public ?int $createdById = null,        public ?string $guestName = null,        public ?string $guestEmail = null,        public ?string $guestPhone = null,        public bool $preserveByEmail = false,        public ?int $parentMessageId = null,        public ?ThreadSourceContextData $sourceContext = null,        public array $participantUserIds = [],    ) {}}

The Factory does not receive ten parameters or decide which ones a concrete resolver needs.

It uses only type. The complete context is passed to the selected resolver, which reads the data relevant to its flow.

This is useful when different thread flows combine guest fields, subject, source context, and participant IDs in different ways.

How Factory Differs From Strategy in the Previous Article

Strategy

Factory

The main abstraction is interchangeable algorithm behavior.

The main abstraction is selecting the object returned to the caller.

SearchProvider itself is strategy behavior.

ThreadResolverFactory returns a resolver.

The context works through one behavior contract.

The caller asks the factory to select an implementation by type.

Focuses on executing an algorithm.

Focuses on the selection or construction boundary.

The patterns partially overlap here because resolvers also expose interchangeable behavior. The specific responsibility of ThreadResolverFactory, however, is selection. That is why this case is useful as a separate Factory article.

What Changes When a New Thread Type Appears

  1. Add a value to ThreadTypeEnum.
  2. Create a resolver implementing ThreadResolverInterface.
  3. Keep its Thread lookup or creation rules inside that resolver.
  4. Add the type -> resolver mapping in CommunicationServiceProvider.
  5. Add tests for the new resolver and its domain invariants.

MessageService::create() should not receive another case containing domain logic.

Resolvers Can Be Tested Separately From MessageService

PHP - Test resolver-specific invariants directly
$resolver = app(    PostDiscussionThreadResolver::class); $firstThread = $resolver->resolve(    new ThreadContextData(        type:            ThreadTypeEnum::POST_DISCUSSION,         subject:            $post,         createdById:            $firstUser->id,    ));

The factory boundary also makes tests easier. Thread invariants do not all need to be verified through Message creation.

The Private resolver can test reuse and participants directly. Group tests roles and duplicates. Support and Feedback test guest data and assignees. Post Discussion tests subject requirements, availability, and reuse.

The integration point in MessageService remains very small.

When a Factory Would Be Overkill

If the system had only one Thread type, ThreadResolverFactory would provide little practical value. MessageService could depend directly on one resolver.

The Factory became useful when the caller remained the same and the contract remained the same, but multiple concrete creation flows appeared.

It is also important not to move domain logic into the factory. If make() starts creating participants, checking Post visibility, or processing guest data, the boundary becomes blurred again.

A good Factory decides who should do the work. It should not take that work away from the selected object.

Conclusion

In ICanUp, ThreadResolverFactory is small, and that is exactly why it is useful.

It isolates the mapping between ThreadTypeEnum and a concrete resolver. MessageService does not know every conversation type. Each resolver owns the rules for its own Thread flow.

Private, Group, Support, Feedback, and Post Discussion can evolve independently while the integration point remains the same: make(type), then resolve(context).

This is a useful Factory Pattern because it does not try to do too much. It removes selection logic from the caller and leaves domain behavior where it belongs.