Tutorial: Email Queue no CodeIgniter 3 e contratos no Laravel
Este tutorial constrói dois fluxos completos. Primeiro, uma fila de e-mails em PHP 7.4 para uma aplicação CodeIgniter 3, executável também no Windows e com persistência intercambiável em memória ou SQL Server. Depois, os mesmos limites funcionais são aplicados a uma plataforma de assinatura de contratos em Laravel com PHP 8+.
Os exemplos usam as cinco primitivas do Maybe sem transformar a biblioteca em framework:
Schemavalida dados na borda;DTOtransporta somente dados validados;Resulttorna falhas esperadas parte da assinatura;Optionrepresenta uma busca que pode não encontrar valor;Asyncparaleliza trabalho independente e limitado por processos.
Os trechos são um ponto de partida arquitetural. Adapte namespaces, autenticação, mailer e observabilidade ao projeto. Credenciais nunca devem fazer parte do payload persistido.
Parte 1 — EmailQueueService no CodeIgniter 3
1. Instalação e bootstrap
Instale o Maybe no diretório da aplicação e habilite o autoload do Composer:
composer require gabrielalmir/maybe// application/config/config.php
$config['composer_autoload'] = FCPATH . 'vendor/autoload.php';Mapeie App\\ para application/ no composer.json da aplicação. Todos os arquivos desta primeira parte usam apenas sintaxe compatível com PHP 7.4.
2. Contrato persistido
Uma mensagem passa por pending, processing, sent ou failed. deduplication_key torna o enqueue idempotente; attempts e available_at permitem retry com backoff.
CREATE TABLE email_queue (
id BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY,
deduplication_key VARCHAR(100) NOT NULL,
recipient NVARCHAR(320) NOT NULL,
subject NVARCHAR(200) NOT NULL,
body NVARCHAR(MAX) NOT NULL,
status VARCHAR(20) NOT NULL,
attempts INT NOT NULL CONSTRAINT DF_email_queue_attempts DEFAULT 0,
available_at DATETIME2 NOT NULL,
claimed_at DATETIME2 NULL,
sent_at DATETIME2 NULL,
last_error NVARCHAR(2000) NULL,
created_at DATETIME2 NOT NULL CONSTRAINT DF_email_queue_created DEFAULT SYSUTCDATETIME(),
CONSTRAINT UQ_email_queue_deduplication UNIQUE (deduplication_key),
CONSTRAINT CK_email_queue_status CHECK (status IN ('pending', 'processing', 'sent', 'failed'))
);
CREATE INDEX IX_email_queue_claim
ON email_queue (status, available_at, id);Use UTC no banco e no worker. Em produção, defina também uma política para devolver itens processing abandonados a pending quando um processo morrer.
3. Validar a entrada com Schema e DTO
<?php
declare(strict_types=1);
namespace App\Email;
use Maybe\DTO\DTO;
use Maybe\Schema\ObjectSchema;
use Maybe\Schema\Schema;
final class QueueEmailDTO extends DTO
{
/** @var string */
public $deduplicationKey;
/** @var string */
public $recipient;
/** @var string */
public $subject;
/** @var string */
public $body;
private function __construct(string $key, string $recipient, string $subject, string $body)
{
$this->deduplicationKey = $key;
$this->recipient = $recipient;
$this->subject = $subject;
$this->body = $body;
}
public static function schema(): ObjectSchema
{
return Schema::shape(array(
'deduplication_key' => Schema::string()->trimmed()->min(1)->max(100),
'recipient' => Schema::string()->trimmed()->max(320)
->regex('/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/'),
'subject' => Schema::string()->trimmed()->min(1)->max(200),
'body' => Schema::string()->min(1),
));
}
protected static function fromValidated(array $validated)
{
return new self(
$validated['deduplication_key'],
$validated['recipient'],
$validated['subject'],
$validated['body']
);
}
}QueueEmailDTO::fromArray() retorna Result<QueueEmailDTO, ValidationErrorBag>: entrada inválida não chega ao repositório e não exige exceção como controle de fluxo.
4. Criar a porta de persistência
O serviço depende de uma interface, e não do Query Builder do CI3. Assim a mesma regra funciona em testes, comandos locais e SQL Server.
<?php
declare(strict_types=1);
namespace App\Email;
use Maybe\Option\Option;
use Maybe\Result\Result;
interface EmailQueueRepository
{
/** @return Result<Option<array<string,mixed>>,string> */
public function findByDeduplicationKey(string $key): Result;
/** @return Result<array<string,mixed>,string> */
public function add(QueueEmailDTO $email): Result;
/** @return Result<array<int,array<string,mixed>>,string> */
public function claim(int $limit): Result;
/** @return Result<bool,string> */
public function markSent(int $id): Result;
/** @return Result<bool,string> */
public function reschedule(int $id, string $reason, int $delaySeconds): Result;
}Option comunica “não existe ainda” sem confundir ausência com erro de infraestrutura. Já indisponibilidade do banco é Err, nunca None.
5. Implementação em memória
Esta implementação é útil para testes e execução em um único processo. Ela não é compartilhada entre requests, workers ou subprocessos do Async.
final class InMemoryEmailQueueRepository implements EmailQueueRepository
{
/** @var array<int,array<string,mixed>> */
private $items = array();
public function findByDeduplicationKey(string $key): Result
{
foreach ($this->items as $item) {
if ($item['deduplication_key'] === $key) {
return Result::ok(Option::some($item));
}
}
return Result::ok(Option::none());
}
public function add(QueueEmailDTO $email): Result
{
$id = count($this->items) + 1;
$this->items[$id] = array(
'id' => $id,
'deduplication_key' => $email->deduplicationKey,
'recipient' => $email->recipient,
'subject' => $email->subject,
'body' => $email->body,
'status' => 'pending',
'attempts' => 0,
'available_at' => gmdate('Y-m-d H:i:s'),
);
return Result::ok($this->items[$id]);
}
public function claim(int $limit): Result
{
$claimed = array();
foreach ($this->items as $id => $item) {
if (count($claimed) >= $limit || $item['status'] !== 'pending') {
continue;
}
$this->items[$id]['status'] = 'processing';
$this->items[$id]['attempts']++;
$claimed[] = $this->items[$id];
}
return Result::ok($claimed);
}
public function markSent(int $id): Result
{
if (!isset($this->items[$id])) {
return Result::err('email_not_found');
}
$this->items[$id]['status'] = 'sent';
return Result::ok(true);
}
public function reschedule(int $id, string $reason, int $delaySeconds): Result
{
if (!isset($this->items[$id])) {
return Result::err('email_not_found');
}
$this->items[$id]['status'] = 'pending';
$this->items[$id]['last_error'] = $reason;
$this->items[$id]['available_at'] = gmdate('Y-m-d H:i:s', time() + $delaySeconds);
return Result::ok(true);
}
}6. Implementação SQL Server para CI3
Receba a conexão CI_DB_query_builder por injeção. Converta exceções/retornos falsos na borda para Result. A reivindicação precisa ser atômica para dois workers não enviarem o mesmo e-mail.
final class SqlServerEmailQueueRepository implements EmailQueueRepository
{
/** @var \CI_DB_query_builder */
private $database;
public function __construct(\CI_DB_query_builder $database)
{
$this->database = $database;
}
public function findByDeduplicationKey(string $key): Result
{
$row = $this->database->where('deduplication_key', $key)
->limit(1)
->get('email_queue')
->row_array();
if ($this->database->error()['code'] !== 0) {
return Result::err('email_queue_lookup_failed');
}
return Result::ok(Option::fromNullable($row ?: null));
}
public function add(QueueEmailDTO $email): Result
{
$saved = $this->database->insert('email_queue', array(
'deduplication_key' => $email->deduplicationKey,
'recipient' => $email->recipient,
'subject' => $email->subject,
'body' => $email->body,
'status' => 'pending',
'attempts' => 0,
'available_at' => gmdate('Y-m-d H:i:s'),
));
if (!$saved) {
return Result::err('email_queue_insert_failed');
}
return $this->findByDeduplicationKey($email->deduplicationKey)
->andThen(static function (Option $stored): Result {
return $stored->okOr('email_queue_insert_not_found');
});
}
public function claim(int $limit): Result
{
$sql = <<<'SQL'
;WITH candidates AS (
SELECT TOP (?) *
FROM email_queue WITH (UPDLOCK, READPAST, ROWLOCK)
WHERE status = 'pending' AND available_at <= SYSUTCDATETIME()
ORDER BY id
)
UPDATE candidates
SET status = 'processing', claimed_at = SYSUTCDATETIME(), attempts = attempts + 1
OUTPUT inserted.*;
SQL;
$query = $this->database->query($sql, array($limit));
if ($query === false) {
return Result::err('email_queue_claim_failed');
}
return Result::ok($query->result_array());
}
public function markSent(int $id): Result
{
$saved = $this->database->where('id', $id)->update('email_queue', array(
'status' => 'sent',
'sent_at' => gmdate('Y-m-d H:i:s'),
'last_error' => null,
));
return $saved ? Result::ok(true) : Result::err('email_queue_update_failed');
}
public function reschedule(int $id, string $reason, int $delaySeconds): Result
{
$availableAt = gmdate('Y-m-d H:i:s', time() + $delaySeconds);
$saved = $this->database->where('id', $id)->update('email_queue', array(
'status' => 'pending',
'available_at' => $availableAt,
'last_error' => substr($reason, 0, 2000),
));
return $saved ? Result::ok(true) : Result::err('email_queue_reschedule_failed');
}
}Os hints UPDLOCK, READPAST e ROWLOCK fazem workers concorrentes pularem linhas já bloqueadas. Configure db_debug como false para o adapter poder converter falhas do driver em Err. A restrição UNIQUE continua sendo a autoridade contra corrida de idempotência: em uma implementação real, traduza a violação de chave única para a leitura do item existente. Não concatene $limit ou dados do usuário no SQL.
7. Serviço idempotente com Result e Option
final class EmailQueueService
{
/** @var EmailQueueRepository */
private $repository;
public function __construct(EmailQueueRepository $repository)
{
$this->repository = $repository;
}
/** @return Result<array<string,mixed>,mixed> */
public function enqueue(array $input): Result
{
return QueueEmailDTO::fromArray($input)
->andThen(function (QueueEmailDTO $email): Result {
return $this->repository
->findByDeduplicationKey($email->deduplicationKey)
->andThen(function (Option $existing) use ($email): Result {
return $existing
->map(static function (array $stored): Result {
return Result::ok($stored);
})
->unwrapOrElse(function () use ($email): Result {
return $this->repository->add($email);
});
});
});
}
}No controller do CI3, faça a composição e converta o Result exatamente uma vez, na borda HTTP:
public function enqueue()
{
$result = $this->email_queue_service->enqueue($this->input->post(null, true));
return $result->match(
function (array $email) {
return $this->output->set_status_header(202)
->set_content_type('application/json')
->set_output(json_encode(array('data' => $email)));
},
function ($error) {
$payload = method_exists($error, 'toArray') ? $error->toArray() : $error;
return $this->output->set_status_header(422)
->set_content_type('application/json')
->set_output(json_encode(array('error' => $payload)));
}
);
}Escolha a persistência na composição da aplicação, sem if dentro da regra de negócio:
$repository = ENVIRONMENT === 'testing'
? new InMemoryEmailQueueRepository()
: new SqlServerEmailQueueRepository($this->db);
$service = new EmailQueueService($repository);8. Worker e Async compatível com Windows
O Async usa proc_open, PHP_BINARY, DIRECTORY_SEPARATOR e NUL no Windows. Verifique que proc_open está habilitado e que o usuário do processo pode executar o PHP CLI e escrever no diretório temporário.
Há uma separação importante:
- o processo pai reivindica itens no SQL Server;
- cada subprocesso recebe somente arrays serializáveis e cria sua própria conexão de transporte;
- o pai aguarda os resultados e atualiza o banco.
Nunca capture $this->db, uma conexão SMTP ou o superobjeto do CI em uma closure assíncrona. Recursos e memória não são compartilhados entre processos.
use Maybe\Async\Async;
final class EmailQueueWorker
{
/** @var EmailQueueRepository */
private $repository;
public function __construct(EmailQueueRepository $repository)
{
$this->repository = $repository;
}
public function runBatch(int $limit = 10): Result
{
return $this->repository->claim($limit)->andThen(function (array $emails): Result {
$tasks = array();
foreach ($emails as $email) {
$tasks[$email['id']] = static function () use ($email): array {
// A classe deve ser carregável pelo Composer dentro do subprocesso.
$transport = TransportFactory::fromEnvironment();
return $transport->send($email);
};
}
try {
$outcomes = Async::pool($tasks, 4, array(
'timeout' => 30.0,
'temp_dir' => sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'maybe-email-queue',
));
} catch (\Throwable $error) {
return Result::err('email_batch_failed: ' . $error->getMessage());
}
return $this->persistOutcomes($emails, $outcomes);
});
}
private function persistOutcomes(array $emails, array $outcomes): Result
{
foreach ($emails as $email) {
$outcome = $outcomes[$email['id']];
$saved = !empty($outcome['sent'])
? $this->repository->markSent((int) $email['id'])
: $this->repository->reschedule(
(int) $email['id'],
(string) $outcome['error'],
min(3600, 30 * (2 ** (int) $email['attempts']))
);
if ($saved->isErr()) {
return $saved;
}
}
return Result::ok(count($outcomes));
}
}Async::pool() lança se uma tarefa falhar como exceção e cancela as restantes; por isso o transporte deve preferencialmente retornar array('sent' => false, 'error' => '...') para falhas esperadas. O limite de quatro evita criar um processo sem controle para cada e-mail.
No Windows, rode um comando CLI do CI3 pelo Agendador de Tarefas, por exemplo C:\\php\\php.exe C:\\app\\index.php email_worker run, com repetição e usuário de serviço. Em Linux, o mesmo controller CLI pode ser acionado por cron ou supervisor. Não use a persistência em memória nesse cenário: cada execução começaria vazia.
9. Checklist de produção
- mantenha a chave única de idempotência e trate sua violação;
- recupere claims abandonados e defina máximo de tentativas/dead-letter;
- registre
queue_id, tentativa, duração e erro sem corpo sensível; - use timeout tanto no
Asyncquanto no cliente SMTP/API; - preserve o erro original em tamanho limitado;
- teste os dois repositórios pelo mesmo contract test;
- use SQL Server para múltiplos processos; memória é somente local;
- mantenha efeitos externos fora de transações longas de banco;
- faça shutdown gracioso e deixe o item recuperável após interrupção.
Parte 2 — Plataforma de assinatura de contratos em Laravel (PHP 8+)
Agora criaremos um exemplo de assinatura recorrente de contratos. O Laravel cuida de HTTP, Eloquent, transações e filas; o Maybe explicita validação, ausência e erros do domínio. Não substitua a fila durável do Laravel por subprocessos dentro de uma request web.
1. Fluxo e modelo de dados
O fluxo é:
- criar um contrato como
draft; - localizar o plano de assinatura ativo;
- criar a cobrança no provedor com chave idempotente;
- ativar a assinatura e o contrato na mesma transação local;
- publicar um job para solicitar a assinatura eletrônica;
- enfileirar o e-mail de convite pelo serviço da primeira parte ou por um adapter Laravel.
Tabelas mínimas: plans, contracts, subscriptions e signature_requests. Use índices únicos para contracts.uuid, subscriptions.provider_reference e a chave idempotente do provedor.
2. DTO de criação
O código da aplicação pode usar recursos do PHP 8+, embora a API do Maybe permaneça compatível com 7.4.
<?php
declare(strict_types=1);
namespace App\Contracts;
use Maybe\DTO\DTO;
use Maybe\Schema\ObjectSchema;
use Maybe\Schema\Schema;
final class CreateContractData extends DTO
{
public function __construct(
public string $title,
public string $signerName,
public string $signerEmail,
public string $planCode,
) {}
public static function schema(): ObjectSchema
{
return Schema::shape([
'title' => Schema::string()->trimmed()->min(3)->max(200),
'signer_name' => Schema::string()->trimmed()->min(2)->max(150),
'signer_email' => Schema::string()->trimmed()->max(320)
->regex('/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/'),
'plan_code' => Schema::string()->trimmed()->min(1)->max(50),
]);
}
protected static function fromValidated(array $validated): static
{
return new static(
$validated['title'],
$validated['signer_name'],
$validated['signer_email'],
$validated['plan_code'],
);
}
}3. Repositório com Option
use App\Models\Plan;
use Maybe\Option\Option;
final class EloquentPlanRepository
{
/** @return Option<Plan> */
public function activeByCode(string $code): Option
{
return Option::fromNullable(
Plan::query()->where('code', $code)->where('active', true)->first()
);
}
}O caso ausente vira um erro de domínio apenas quando o caso de uso exige um plano:
$planResult = $plans->activeByCode($data->planCode)
->okOr(['code' => 'plan_not_found', 'retryable' => false]);4. Gateway externo com Result
interface BillingGateway
{
/** @return Result<array{reference:string},array{code:string,retryable:bool}> */
public function subscribe(Plan $plan, string $email, string $idempotencyKey): Result;
}
final class HttpBillingGateway implements BillingGateway
{
public function subscribe(Plan $plan, string $email, string $idempotencyKey): Result
{
try {
$response = Http::timeout(10)
->withHeaders(['Idempotency-Key' => $idempotencyKey])
->post(config('services.billing.url') . '/subscriptions', [
'plan' => $plan->code,
'email' => $email,
])
->throw();
return Result::ok(['reference' => $response->json('id')]);
} catch (\Throwable $error) {
report($error);
return Result::err(['code' => 'billing_unavailable', 'retryable' => true]);
}
}
}O gateway captura exceções porque está na borda de uma API que lança. O restante do domínio recebe um erro estruturado e decide se deve fazer retry.
5. Caso de uso transacional
final class CreateContract
{
public function __construct(
private EloquentPlanRepository $plans,
private BillingGateway $billing,
) {}
/** @return Result<Contract,mixed> */
public function execute(array $input): Result
{
return CreateContractData::fromArray($input)
->mapErr(static fn ($errors): array => [
'code' => 'validation_failed',
'retryable' => false,
'details' => $errors->toArray(),
])
->andThen(fn (CreateContractData $data): Result =>
$this->plans->activeByCode($data->planCode)
->okOr(['code' => 'plan_not_found', 'retryable' => false])
->andThen(fn (Plan $plan): Result => $this->create($data, $plan))
);
}
private function create(CreateContractData $data, Plan $plan): Result
{
$uuid = (string) Str::uuid();
return $this->billing
->subscribe($plan, $data->signerEmail, 'contract:' . $uuid)
->andThen(function (array $charge) use ($data, $plan, $uuid): Result {
try {
$contract = DB::transaction(function () use ($data, $plan, $uuid, $charge) {
$contract = Contract::create([
'uuid' => $uuid,
'title' => $data->title,
'signer_name' => $data->signerName,
'signer_email' => $data->signerEmail,
'status' => 'awaiting_signature',
]);
$contract->subscription()->create([
'plan_id' => $plan->id,
'provider_reference' => $charge['reference'],
'status' => 'active',
]);
return $contract;
});
} catch (\Throwable $error) {
report($error);
return Result::err(['code' => 'contract_persistence_failed', 'retryable' => true]);
}
RequestElectronicSignature::dispatch($contract->id)->afterCommit();
return Result::ok($contract);
});
}
}Em produção, uma cobrança externa bem-sucedida seguida de falha local exige compensação ou, preferencialmente, saga/outbox. Uma transação SQL não torna a API do provedor atômica. A chave idempotente permite repetir o comando sem nova cobrança.
6. Controller como adaptador HTTP
final class ContractController
{
public function store(Request $request, CreateContract $useCase): JsonResponse
{
return $useCase->execute($request->all())->match(
static fn (Contract $contract): JsonResponse => response()->json([
'data' => ['uuid' => $contract->uuid, 'status' => $contract->status],
], 201),
static function (array $error): JsonResponse {
$status = $error['code'] === 'validation_failed' ? 422 : 409;
return response()->json(['error' => $error], $status);
},
);
}
}7. Job de assinatura e convite
O job pode chamar o provedor de assinatura e transformar a resposta em Result. Após persistir a solicitação, ele enfileira o convite com uma chave determinística:
final class RequestElectronicSignature implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $contractId) {}
public function handle(SignatureGateway $signatures, EmailQueueService $emails): void
{
$contract = Contract::query()->find($this->contractId);
Option::fromNullable($contract)
->okOr(['code' => 'contract_not_found', 'retryable' => false])
->andThen(fn (Contract $item): Result => $signatures->request($item))
->andThen(function (array $signature) use ($contract, $emails): Result {
$contract->signatureRequests()->updateOrCreate(
['provider_reference' => $signature['reference']],
['url' => $signature['url'], 'status' => 'pending'],
);
return $emails->enqueue([
'deduplication_key' => 'contract-signature:' . $contract->uuid,
'recipient' => $contract->signer_email,
'subject' => 'Assine o contrato ' . $contract->title,
'body' => 'Acesse: ' . $signature['url'],
]);
})
->match(
static function (): void {},
function ($error): void {
$retryable = is_array($error) && !empty($error['retryable']);
if ($retryable) {
$this->release(60);
return;
}
$code = is_array($error) ? (string) $error['code'] : 'email_validation_failed';
$this->fail($code);
},
);
}
}Para um Laravel distribuído, implemente EmailQueueRepository com Eloquent/SQL Server ou adapte EmailQueueService para publicar um job Laravel. O contrato do serviço continua igual.
8. Onde usar Async no Laravel
Prefira Laravel Queue para cobrança, assinatura e e-mail: ela é durável, observável e possui retry. Maybe\\Async é apropriado dentro de um comando CLI para tarefas independentes, puras ou facilmente repetíveis, como gerar previews de páginas já persistidas:
$previews = Async::pool(
collect($pages)->mapWithKeys(
static fn (array $page): array => [
$page['number'] => static fn (): array => PreviewRenderer::render($page),
]
)->all(),
4,
['timeout' => 20.0]
);Não passe models Eloquent conectados, containers, conexões PDO ou closures que capturem serviços. Passe arrays serializáveis, recrie recursos no filho e persista no processo pai. No Windows valem os mesmos requisitos de proc_open e PHP CLI da primeira parte.
Estratégia de testes
- teste
QueueEmailDTOcom destinatário inválido e limites de texto; - execute o mesmo contract test contra os repositórios em memória e SQL Server;
- prove que duas chamadas com a mesma chave retornam uma única mensagem;
- execute dois workers concorrentes e confirme que um item é reivindicado uma vez;
- simule timeout e falha transitória para validar backoff e dead-letter;
- no Laravel, use fakes dos gateways e
Queue::fake(); - teste separadamente a compensação/outbox após sucesso remoto e falha local;
- mantenha um teste de integração real para cada driver/serviço externo homologado.
O ganho central não é encadear métodos: é tornar cada fronteira explícita. Schema e DTO protegem a entrada, Option diferencia ausência, Result diferencia sucesso de falha recuperável e Async fica restrito ao trabalho realmente independente. A porta de persistência permite trocar memória, SQL Server e Eloquent sem reescrever a regra de negócio.