luminix / sheets
Import and export spreadsheets for Luminix models
Requires
- php: ^8.2
- laravel/framework: ^11.0|^12.0|^13.0
- luminix/backend: ^1.0
- openspout/openspout: ^4.28|^5.3
Requires (Dev)
- laravel/pint: ^1.18
- luminix/frontend: ^1.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
- luminix/frontend: Exposes the importable/exportable flags in the model manifest.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-23 14:12:47 UTC
README
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/backend1.x -
luminix/frontend1.x — opcional, expõe as flagsimportable/exportableno 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.
-
201com{"message": "...", "count": 12}quando tudo entra; -
422com 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: oluminix/adminmandatrans('*')— 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 comprefix: ':'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}.jsonda própria aplicação. OFileLoaderdo Laravel mescla os caminhos dos pacotes primeiro e o da aplicação por último, então a aplicação ganha. Não hávendor:publishde tradução — um JSON emlang/vendornã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:
- o resultado é lido do banco em lotes de
export.chunk_size; - cada linha é escrita no arquivo assim que é mapeada;
- 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.