Search by

luminix / sheets

obrunopolo

Import and export spreadsheets for Luminix models

1.0.0 2026-09-23 12:21 UTC

This package is auto-updated.

Last update: 2026-09-23 14:12:47 UTC


README

Laravel PHP Luminix Licença

Importação e exportação de planilhas para modelos do Luminix. Marque um model com #[Exportable] ou #[Importable] e o pacote injeta as rotas GET .../export e POST .../import no conjunto que o luminix/backend já gera, com as mesmas permissões, filtros e ordenação da listagem.

A leitura e a escrita são feitas linha a linha com OpenSpout: o consumo de memória é limitado pelo tamanho do lote, não pelo número de linhas.

Requisitos

  • /arandu — obrigatória para trabalhar neste repositório com Claude Code:

    gh api -H "Accept: application/vnd.github.raw" repos/AranduTech/arandu-skill/contents/install.sh | bash
  • PHP 8.2 ou superior

  • Laravel 11, 12 ou 13

  • luminix/backend 1.x

  • luminix/frontend 1.x — opcional, expõe as flags importable / exportable no manifesto consumido pelo frontend

Instalação (ambiente de desenvolvimento)

git clone https://github.com/luminix-cms/luminix-sheets.git
cd luminix-sheets
composer install
composer test

O pacote é uma biblioteca: não sobe serviço nem tem URL ou credencial semeada. A suíte roda sobre orchestra/testbench com SQLite em memória, e a aplicação de teste vive em workbench/.

Comandos disponíveis:

Comando O que faz
composer test Roda a suíte completa
composer test:coverage Roda a suíte com relatório de cobertura (exige Xdebug ou PCOV)
composer lint Verifica o estilo com Pint, sem alterar arquivos
composer format Aplica o Pint

Instalação no projeto

composer require luminix/sheets

Para publicar a configuração:

php artisan vendor:publish --tag=luminix-sheets-config

Uso

Habilitar um model

use Luminix\Sheets\Exportable;
use Luminix\Sheets\Importable;

#[Exportable]
#[Importable]
class Player extends Model
{
    use LuminixModel;

    protected $fillable = ['name', 'registration', 'score'];
}

Isso registra, dentro do conjunto de rotas do luminix/backend:

Rota Nome Permissão
GET {prefixo}/players/export luminix.player.export read-player
POST {prefixo}/players/import luminix.player.import create-player

As rotas entram antes de show e update, que também casam com players/{id} e engoliriam players/export.

Exportar

GET /luminix-api/players/export devolve um download em xlsx.

A consulta é a mesma da listagem: o escopo allowed da permissão, q, where, tab e order_by valem igual. Exportar o que está na tela é passar os mesmos parâmetros:

GET /luminix-api/players/export?q=ana&order_by=score:desc

Importar

POST /luminix-api/players/import com multipart/form-data e um campo file.

  • 201 com {"message": "...", "count": 12} quando tudo entra;

  • 422 com os erros indexados pelo número da linha na planilha, como a pessoa que abre o arquivo os conta:

    {
      "message": "The import file contains 1 row(s) with validation errors.",
      "errors": { "4": { "total": ["O total precisa ser um número."] } }
    }

Também respondem 422, com message e sem errors: um arquivo que não é uma planilha, um que o leitor não consegue abrir, e um acima de import.max_rows.

O upload é conferido pelo nome e pelo conteúdo. Como um csv não tem assinatura própria — a detecção o chama de text/plain — e um xlsx é um zip, o pacote confere contra o que a detecção realmente devolve para cada formato habilitado; o que passa disso e não for planilha morre no leitor, com o mesmo 422.

Por padrão a importação inteira roda em uma transação: uma linha que falha no banco desfaz todas as anteriores.

Configuração

config/luminix/sheets.php:

'routes' => [
    'enabled' => true,          // desligue para registrar as rotas você mesmo
],

'permissions' => [
    'export' => 'read',         // null desliga o gate E o escopo de linha
    'import' => 'create',
],

'import' => [
    'max_file_size_kb' => 10240,
    'formats' => ['xlsx'],
    'chunk_size' => 500,        // linhas gravadas por lote
    'max_rows' => null,         // teto de linhas por requisição
],

'export' => [
    'default_format' => 'xlsx', // xlsx | csv | ods
    'chunk_size' => 1000,       // linhas por ida ao banco
    'max_rows' => null,         // teto de linhas por requisição
],

O luminix/backend não tem entrada para export/import no próprio mapa de permissões, então os verbos são resolvidos aqui. Um verbo null desliga tanto o Gate quanto o escopo allowed daquela ação.

Traduções

Um arquivo só: lang/pt-BR.json, no formato de tradução JSON do Laravel — a linha em inglês é a chave, então não existe arquivo en: um locale sem tradução cai na própria chave e continua legível.

__('Please upload a spreadsheet file.');

Ele cobre os dois lados da funcionalidade:

  • As mensagens HTTP das rotas de importação/exportação (autorização, erros de upload, resultado da importação).
  • Os rótulos do CMS. O @luminix/sheets-for-mui-cms (o pacote npm irmão) não carrega dicionário próprio: o luminix/admin manda trans('*') — todo o dicionário JSON — no payload de boot, e é dali que o i18next do CMS lê.

Duas coisas que decorrem disso:

  • Os placeholders são estilo Laravel (:model), nunca {{model}} — o CMS inicializa o i18next com prefix: ':' e sufixo vazio, então um placeholder no formato do i18next aparece literal na tela.
  • Para sobrescrever qualquer linha, repita a chave no lang/{locale}.json da própria aplicação. O FileLoader do Laravel mescla os caminhos dos pacotes primeiro e o da aplicação por último, então a aplicação ganha. Não há vendor:publish de tradução — um JSON em lang/vendor não é lido de volta.

Três chaves não são frases, e decidem o conteúdo do arquivo exportado pelo DefaultExportable:

Chave Sem tradução (en) pt-BR
Yes / No Yes / No Sim / Não
m/d/Y H:i m/d/Y H:i d/m/Y H:i

Ou seja, o booleano e o formato de data seguem o locale da aplicação. Uma aplicação em en que esperava Sim/Não precisa de APP_LOCALE=pt-BR ou de sobrescrever as duas chaves.

Um novo idioma é um lang/{locale}.json a mais no pacote, ou as chaves no arquivo da própria aplicação.

Handlers

Sem handler declarado, o pacote usa DefaultExportable / DefaultImportable: as colunas são o $fillable menos as ocultas, os rótulos são os nomes em Title Case, datas saem como d/m/Y H:i, booleanos como Sim / Não e enums pelo value.

Para assumir o controle, gere um handler:

php artisan make:export PlayerExport
php artisan make:import PlayerImport

e aponte o model para ele:

#[Exportable(handler: PlayerExport::class)]
#[Importable(handler: PlayerImport::class)]
class Player extends Model { /* ... */ }

Exportação

Método Para quê
headers() Os rótulos das colunas, em ordem. É isso que define o formato do arquivo — uma exportação sem resultados ainda sai com cabeçalho.
widths() Largura por rótulo
map(Model $model) Uma linha, indexada pelos rótulos de headers()
columns() Restringe os atributos usados pelos padrões acima
query(Builder $query) Restrições extras sobre a consulta já filtrada
fileName() / sheetName() / format() Nome do arquivo, da aba e formato
beforeExport(LazyCollection $rows) / afterExport() Ganchos

beforeExport() recebe o resultado preguiçoso. Iterar ali carrega tudo em memória e anula o streaming — leia dele só quando precisar mesmo de um segundo passo.

Importação

Método Para quê
map(array $row, int $rowIndex) Linha → atributos; null pula a linha
rules() / messages() Validação por linha
allowedColumns() Colunas aceitas do arquivo
headingRow() Em que linha está o cabeçalho
useTransaction() Envolver tudo em uma transação
beforeImport(UploadedFile $file) Gancho, antes da primeira linha
afterChunk(Collection $imported) Gancho, uma vez por lote gravado
afterImport(int $imported) Gancho, uma vez no fim, com o total

allowedColumns() é aplicado pelo motor, não só pelo handler padrão: um handler que sobrescreve map() e devolve uma coluna fora da lista não consegue gravá-la.

O pós-processamento por registro vai em afterChunk(), que recebe só o lote recém-gravado. afterImport() recebe um número, e não os models, justamente porque guardar todos eles é o que uma importação em lotes evita — um handler que acumula o que afterChunk() entrega é a única coisa que ainda faz a importação crescer com o arquivo.

Colunas ocultas

O model decide o que nunca entra na planilha. As propriedades podem ser protected; os métodos precisam ser públicos.

class Player extends Model
{
    // Ocultas na importação e na exportação. Padrão: o $hidden do model.
    protected array $sheetsHidden = ['secret_note'];

    // Só na exportação
    protected array $sheetsHiddenForExport = ['internal'];

    // Só na importação
    protected array $sheetsHiddenForImport = ['computed'];
}

A chave primária nunca é importável.

Streaming e memória

O pacote não monta a planilha em memória, nos dois sentidos. Na exportação:

  1. o resultado é lido do banco em lotes de export.chunk_size;
  2. cada linha é escrita no arquivo assim que é mapeada;
  3. o arquivo é fechado antes de a resposta começar.

O passo 3 é deliberado: uma falha no meio da escrita vira 500, e não um 200 carregando um anexo truncado. O arquivo temporário é removido tanto no erro quanto ao fim do download.

Na importação, o arquivo é lido linha a linha e gravado em lotes de import.chunk_size. Nada além de um lote fica retido: 10.000 linhas custam o mesmo que 100. import.max_rows recusa com 422 um arquivo grande demais para uma requisição síncrona — acima disso o trabalho é de um job em fila, não de um POST.

A leitura toda acontece mesmo depois da primeira linha inválida, para que o 422 liste todas de uma vez. A gravação, essa, para na primeira: dentro de uma transação os lotes já gravados voltam atrás, e sem transação eles ficam — que é o que useTransaction() desligado quer dizer.

Todo valor é gravado como texto, de modo que uma matrícula 007 continua 007 em vez de virar 7.

Licença

MIT. Veja LICENSE.