All skills
labrodev avatar

/labrodev-infrastructure

@872cdeb

Use when integrating any external system in a Labrodev Laravel project — payment gateways, email/SMS/messenger providers, ERP or CRM APIs, webhooks, file storage, third-party SDKs — or when creating/reviewing anything under Core/Infrastructure: contracts, per-vendor adapters, resolvers, and external-data mapping.

  • 1 file
  • 6.5 KB
  • MIT
  • Updated last month
  • GitHub

Use this Skill: https://skilld.dev/gh/labrodev/laravel-playbook/labrodev-infrastructure

This session only. Nothing lands on disk.

SKILL.md

≈86 tokens always: the name and description. ≈1.5k when used: this file.

Infrastructure: adapters between the Domain and the outside world

Part of the Labrodev playbook. The law for this component lives in the always-on labrodev-infrastructure guideline (musts, must-nots); the per-file checklist is rules/infrastructure.md. This skill holds the craft: anatomy, canonical templates, and edge cases.

Four pieces make up the pattern: a contract the Domain depends on, one adapter per vendor implementing it, a resolver for when the vendor is chosen at runtime, and a boundary DTO mapping vendor payloads into Domain-friendly shapes. Splitting it this way means adding or swapping a vendor never touches Domain code.

The pattern: contract → adapters → resolver

Worked example — one messaging capability, several vendors:

Core/Infrastructure/Messaging/
├── Contracts/
│   └── MessageNotifier.php          the capability contract
├── SlackNotifier.php                per-vendor adapter
├── TelegramNotifier.php             per-vendor adapter
├── MessageNotifierResolver.php      picks the adapter by domain enum
└── OutboundMessagePayload.php       boundary DTO (final readonly)

Contract

<?php

declare(strict_types=1);

namespace Core\Infrastructure\Messaging\Contracts;

use Core\Infrastructure\Messaging\OutboundMessagePayload;

interface MessageNotifier
{
    public function send(OutboundMessagePayload $outboundMessagePayload): void;
}

Boundary DTO

<?php

declare(strict_types=1);

namespace Core\Infrastructure\Messaging;

final readonly class OutboundMessagePayload
{
    public function __construct(
        public string $recipient,
        public string $subject,
        public string $body,
    ) {}
}

Adapter (one per vendor)

<?php

declare(strict_types=1);

namespace Core\Infrastructure\Messaging;

use Core\Infrastructure\Messaging\Contracts\MessageNotifier;
use Illuminate\Support\Facades\Http;

final readonly class SlackNotifier implements MessageNotifier
{
    public function __construct(
        private string $webhookUrl,
    ) {}

    public function send(OutboundMessagePayload $outboundMessagePayload): void
    {
        Http::asJson()
            ->post($this->webhookUrl, [
                'text' => sprintf('%s — %s', $outboundMessagePayload->subject, $outboundMessagePayload->body),
            ])
            ->throw();
    }
}

Resolver (when the vendor is chosen at runtime)

<?php

declare(strict_types=1);

namespace Core\Infrastructure\Messaging;

use Core\Domain\Channel\Enums\ChannelType;
use Core\Infrastructure\Messaging\Contracts\MessageNotifier;

final readonly class MessageNotifierResolver
{
    public function __invoke(ChannelType $channelType): MessageNotifier
    {
        return match ($channelType) {
            ChannelType::Slack => resolve(SlackNotifier::class),
            ChannelType::Telegram => resolve(TelegramNotifier::class),
        };
    }
}

Wiring (deliberate, in a provider)

$this->app->when(SlackNotifier::class)
    ->needs('$webhookUrl')
    ->giveConfig('services.slack.webhook_url');

Consuming from the Domain

A Pipeline step, Service, or Job injects the contract (single vendor) or the resolver (runtime vendor) — never a concrete adapter:

final readonly class PushBookingToCrm
{
    public function __construct(
        private CrmClient $crmClient,
    ) {}
}

Inbound payload mapping (fail loud)

Inbound vendor data is mapped by a static factory on the DTO (or a dedicated {Thing}Mapper when the mapping is large). The mapping commits to the vendor's documented contract — one payload key per field, required fields throw, nullable only where the docs say optional (→ labrodev-core contract commitment):

<?php

declare(strict_types=1);

namespace Core\Infrastructure\Crm;

use Core\Infrastructure\Crm\Exceptions\CrmContactPayloadException;

final readonly class CrmContactPayload
{
    public function __construct(
        public int $externalId,
        public string $email,
        public ?string $phone,
    ) {}

    /**
     * @param  array<string, mixed>  $payload
     */
    public static function fromPayload(array $payload): self
    {
        $externalId = $payload['id'] ?? null;
        $email = $payload['email'] ?? null;
        $phone = $payload['phone'] ?? null;

        if (! is_int($externalId) || ! is_string($email) || ($phone !== null && ! is_string($phone))) {
            throw CrmContactPayloadException::make(payload: $payload);
        }

        return new self(
            externalId: $externalId,
            email: $email,
            phone: $phone,
        );
    }
}

?? null here is isset-safe reading of the one documented key, immediately followed by a throw — not a fallback. The exception lives in Core/Infrastructure/{Integration}/Exceptions and follows the make() convention (→ labrodev-exception skill).

The anti-pattern this exists to prevent — hedged mapping that guards against imagined shape variants:

// ❌ contract-blind: guesses keys, coerces everything to null
$firstname = $this->stringOrNull($client['first_name'] ?? $client['firstname'] ?? null);
$phone = $this->stringOrNull($client['mobile'] ?? $client['phone'] ?? null);

When the vendor renames a key, the hedged version silently writes null into persisted data; the strict mapper throws at the boundary, where the bug is visible and attributable. If the payload shape is genuinely unknown, capture a real payload or read the vendor docs before writing the mapper — the fallback chain is never the answer.

Placement decision

Situation Home
Talking to an external system (transport, auth, mapping) Core/Infrastructure/{Integration}
Deciding whether/when to talk to it Domain Action/Service/Rule
Multi-step flow that includes external calls Pipeline with an Infrastructure-calling step → see the labrodev-pipeline skill
Async/retryable external work Queued Job delegating to the contract
Generic technical helper with no external system Core/Support → see the labrodev-core skill

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at 872cdeb. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last month.

Activeupdated last month
metadata
{
  "author": "labrodev"
}

README badge

README badge for labrodev/laravel-playbook/labrodev-infrastructure