Resources
5Install
npx skillscat add arqel-dev/export Install via the SkillsCat registry.
SKILL.md
SKILL.md — arqel-dev/export
Contexto canônico para AI agents.
Purpose
arqel-dev/export entrega a pipeline de exportação do Arqel — converte a seleção de uma Table (ou um dataset arbitrário) em arquivos CSV, XLSX ou PDF. Cobre RF-T-14. O pacote é só o esqueleto (interfaces + enum + stubs); as implementações reais ficam atrás de suggest: em composer.json para que panels que não exportam nada não precisem instalar spatie/simple-excel nem dompdf/dompdf.
Status
Entregue (EXPORT-001):
- Esqueleto do pacote
arqel-dev/exportcom PSR-4Arqel\Export\→src/, deps emarqel-dev/coreearqel-dev/actionsvia path repo Arqel\Export\ExportFormat— enumstringcom casosCSV/XLSX/PDF+ métodosmimeType(): stringeextension(): string. Single source of truth para Content-Type headers e filenamesArqel\Export\Contracts\Exporter— interfaceexport(iterable $rows, array $columns, string $destination): string(retorna o path escrito)Arqel\Export\Exporters\XlsxExporter|PdfExporter—final classimplementandoExporter. Bodies lançamRuntimeExceptionapontando para EXPORT-003/004 (CsvExporter já real — ver EXPORT-002 abaixo)Arqel\Export\Actions\ExportAction—finalaction bulk pré-configurada com label'Export'+ icon'download'. Factorymake(string $name = 'export'), fluentformat(ExportFormat)+ gettergetFormat().execute()lançaRuntimeException("Wired in EXPORT-005")(stub posture). Detalhe técnico: a spec original do ticket pedeextends BulkAction, masArqel\Actions\Types\BulkActionéfinal. Como o ticket proíbe modificar outros pacotes,ExportActionestendeArqel\Actions\Actiondirectamente e emitetype = 'bulk'— consumidores tratam-na como BulkAction sem nenhuma diferença observável no payload Inertia. Chunking +deselectRecordsAfterCompletionvoltam quando a wiring real chegar em EXPORT-005Arqel\Export\ExportServiceProviderauto-discovered viaextra.laravel.providers(extendsSpatie\LaravelPackageTools\PackageServiceProvider). Sem migrations, sem config, sem routes — todos esses ficam em tickets posteriores- Tests Pest cobrindo enum, contract stubs, ExportAction defaults + fluent setter, ServiceProvider boot
Entregue (EXPORT-002):
Arqel\Export\Exporters\CsvExporter— implementação real backed porspatie/simple-excel(SimpleExcelWriter::create($destination)). Header derivado decolumn['label'] ?? column['name']; cells formatadas porformatCell()com handling explícito paradate(Y-m-dquandoDateTimeInterface),boolean(Yes/No),relationship(seguedisplay_path) e fallback(string) $value(null →''). Streaming row-by-row, sem->all()/->get()— memory constante mesmo em datasets grandes. UTF-8 BOM ligado por default (Excel-on-Windows)CsvExporter::streamDownload(iterable $rows, array $columns, string $filename): StreamedResponse— helper estáticostaticpara o caminho HTTP sync. Devolve umSymfony\Component\HttpFoundation\StreamedResponsecomContent-Type: text/csv; charset=UTF-8+Content-Disposition: attachmentque invocaSimpleExcelWriter::streamDownload()dentro do callback. Mantém o contrato file-based (export()) intacto — é apenas um conveniência para downloads sync de datasets pequenos. Datasets grandes continuam a passar pelo pipeline async (ExportAction+ProcessExportJob, EXPORT-005+)spatie/simple-excel: ^3.0promovido desuggestpararequire(deixa de ser opcional para o pacote — apps que não exportam continuam a poder excluir manualmente).dompdf/dompdfcontinua emsuggestaté EXPORT-004- Pest tests
tests/Unit/CsvExporterTest.phpcobrindo: header+rows + return value, empty iterable (só header), boolean → Yes/No, date →Y-m-d, relationship →display_path, fallback de label, null cell em row mista.ExportersTestmantém apenas as asserções de RuntimeException para XLSX/PDF (CSV deixou de lançar)
Entregue (EXPORT-003):
Arqel\Export\Exporters\XlsxExporter— implementação real backed porspatie/simple-excel(SimpleExcelWriter::create($destination); OpenSpout under the hood). Mesma estrutura doCsvExporter(header derivado decolumn['label'] ?? column['name'], streaming row-by-row, contratoexport(iterable $rows, array $columns, string $destination): string) com uma diferença chave:formatCell()preserva tipos nativos quando útil para Excel —DateTimeInterfaceflui inalterado (Excel renderiza como data real, não stringY-m-d); scalars passam through; sóboolean(Yes/No) erelationship(display_path→data_get) são stringificados. Header row é negrito viasetHeaderStyle((new Style)->setFontBold())XlsxExporter::streamDownload(iterable $rows, array $columns, string $filename): StreamedResponse— helper estático mirror doCsvExporter::streamDownload, mas comContent-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheete usandoSimpleExcelWriter::streamDownload($filename)(SimpleExcel infere o formato pela extensão do filename). Contrato file-based intacto- Trade-off documentado: frozen header row + auto column widths ficam de fora —
spatie/simple-excelv3 não expõe helpers first-class e mexer em internals do OpenSpout introduz acoplamento frágil.// TODO(EXPORT-XXX)comment no código se um ticket futuro decidir adicionar - Pest tests
tests/Unit/XlsxExporterTest.php(6 cenários) com round-trip read viaSimpleExcelReader::create($path)->noHeaderRow()->getRows()para asserir conteúdo (header+rows, empty iterable, boolean → Yes/No, DateTime preservado via assertioninstanceof DateTimeInterface, relationshipdisplay_path, fallback de label).ExportersTestdeixou de asserir RuntimeException para XLSX — só PDF ainda stub
Entregue (EXPORT-004):
Arqel\Export\Exporters\PdfExporter— implementação real backed pordompdf/dompdf. Renderiza um HTML mínimo (<table>simples com styling inline, font defaultDejaVu Sanspara suportar Unicode sem registrar fonts custom) e passa peloDompdf::loadHtml()+setPaper()+render(); o output é escrito em$destinationviafile_put_contents(). Não há dependência em Blade neste ticket — o template default é uma string PHP — para manter o footprint do pacote pequeno. Override via Blade (Resource::pdfView()) chega em EXPORT-005PdfExporter::setOrientation(string $orientation): static+setPaperSize(string $size): static— fluent setters aplicados em cadarender(). Defaults'portrait'/'a4'. Aceitam qualquer string que dompdf entenda ('landscape','letter','legal', etc.)PdfExporter::streamDownload(iterable $rows, array $columns, string $filename): StreamedResponse— helper estático mirror do CSV/XLSX para downloads sync. Renderiza para memória e devolveContent-Type: application/pdf. Em datasets grandes, continuar a usar o pipeline async (ExportAction+ProcessExportJob, EXPORT-005+)formatCell()espelha o doCsvExporter— sempre stringifica (date→Y-m-d,boolean→Yes/No,relationship→data_get($record, $display_path ?? $name), fallback(string) $valuecom null →''). Toda saída passa porhtmlspecialchars()antes de ir para o HTML para evitar quebra de layoutdompdf/dompdf: ^3.0promovido desuggestpararequire— deixa de ser opcional. Apps que não exportam PDF continuam a poder excluir manualmente viareplace/exclude-from-classmapse quiserem- Pest tests
tests/Unit/PdfExporterTest.php(8 cenários) com guardmarkTestSkippedseDompdf\Dompdfouext-mbstringnão estiverem disponíveis. Cobertura: happy path com assertion dos 4 bytes mágicos%PDF, empty rows ainda gera PDF válido,setOrientation/setPaperSizefluentes e persistentes (via reflexão na property privada),formatCellpara boolean/date/relationship/scalar (também via reflexão — mais barato que parsear o PDF).ExportersTestdeixou de asserir RuntimeException — todos os 3 exporters são reais agora
Entregue (EXPORT-005 — escopo reduzido):
Arqel\Export\Actions\ExportAction::execute(mixed $record, array $data)wired pela primeira vez. Resolve oExportercorreto a partir de$this->format(CsvExporter/XlsxExporter/PdfExporter), constrói filename'export-' . date('Ymd-His') . '.' . $format->extension(), escreve emrtrim($destinationDir, '/') . '/' . $filenamechamando$exporter->export($record, $columns, $destination), e devolve['path' => ..., 'filename' => ..., 'format' => $format->value, 'mimeType' => $format->mimeType()].$recordé aCollection|Traversable|iterableque o pipelineBulkActionpassa; scalar/null lançaInvalidArgumentExceptionwithColumns(array),withDestinationDir(string),dryRun(bool=true)— fluent setters.dryRunbypassa exporter e devolve['path' => 'dry-run', ...]para tests + previews- Pest tests
tests/Unit/ExportActionExecuteTest.php(9 cenários) - Form modal + queue threshold dispatch + signed URLs deferred para EXPORT-006/008+
Entregue (EXPORT-006 — escopo reduzido):
Arqel\Export\Jobs\ProcessExportJob—final class implements ShouldQueue(usesDispatchable,InteractsWithQueue,Queueable,SerializesModels). Construtor com props readonly:string $exportId(UUID injectado pelo caller),ExportFormat $format,array $columns,class-string<RecordsResolver> $recordsResolverClasse?string $destinationDir = null.handle(ExportLogger $logger): voidresolve o resolver via container (app($recordsResolverClass)), validainstanceof RecordsResolver, escolhe o exporter pormatch($format), garante o diretório (mkdirrecursivo) e escreve<dir>/export-<exportId>.<ext>. Em sucesso chama$logger->logCompleted(...); em qualquerThrowablechama$logger->logFailed(...)e re-lançaArqel\Export\Contracts\RecordsResolver— interface single-methodresolve(): iterable. Trade-off chave: o job armazena apenas a FQCN, NÃO a coleção serializada — evita payloads de fila gigantesArqel\Export\Contracts\ExportLogger— interface lifecycle (logQueued,logCompleted,logFailed). Default bindingNullExportLoggerviasingletonIf— apps consumidoras sobrescrevemArqel\Export\Http\Controllers\ExportDownloadController—download(string $exportId, Request)fazglob('<dir>/export-{exportId}.*'), abort 400 UUID inválido, 404 se 0 ou >1 matches. Content-Type viaExportFormat::tryFrom(...)?->mimeType(). Sem auth check — consumer wraps com middleware própria- Rota
routes/admin.php→GET /admin/exports/{exportId}/download(namearqel.export.download, where[a-f0-9-]+) - Tests: 6 ProcessExportJob + 4 ExportDownloadController + 1 ServiceProvider binding
Por chegar (EXPORT-007..010):
- Override de template Blade (
Resource::pdfView()+pdfOrientation()) — EXPORT-007 - Cleanup scheduler (
exports:prune) — EXPORT-008 - Signed URLs + ownership policy bundled — EXPORT-009
- Suite full + SKILL.md final — EXPORT-010
Conventions
declare(strict_types=1)obrigatório- Hard deps em libs de export (simple-excel, dompdf) ficam em
suggest:até serem efetivamente exigidas pelo exporter correspondente — apps que só usam CSV não pagam o custo de instalar dompdf Exporter::export()recebe$destinationabsoluto (já resolvido pelo caller viastorage_path()ou disk-aware path); o exporter só escreve, não decide localizaçãoExportFormat::extension()devolve sem ponto inicial ('csv', não'.csv'); o ponto é responsabilidade do construtor de filename
Anti-patterns
- ❌ Carregar dataset inteiro em memória — todos os exporters reais são streaming (
iterable/generator). Nunca->all()ou->get()antes de passar ao exporter - ❌ Hardcoded paths dentro do exporter —
$destinationé injectado, não derivado destorage_path()no exporter - ❌ Bypass do
ExportActionpara downloads sync — em datasets grandes (>1k rows) gera timeout. Use sempre o pipeline async (ProcessExportJob+ signed URL) que chega em EXPORT-005..008 - ❌ Estender
BulkActionpara nova action de export custom —BulkActionéfinal. EstendaExportActionouActiondirectamente
Related
- Tickets: `PLANNING/09-fase-2-essenciais.md` §EXPORT-001..010
- Source: `packages/export/src/`
- Tests: `packages/export/tests/`
- ADRs: