Todo sistema começa inocente. Primeiro vem uma tabela users, depois uma products, depois uma orders, depois uma gambiarra chamada temporary_final_final_v2, e quando você percebe, o banco de dados virou um castelo medieval sustentado por migrations, café e fé. Nesse momento, existe uma pergunta que separa os adultos dos aventureiros: você tem backup?
Fala dev, blz?
Vamos construir uma página de backup dentro do FilamentPHP 4.0, usando uma página customizada no painel administrativo. A ideia é simples e poderosa: criar um botão chamado Executar Backup Agora, gerar um dump .sql do banco MySQL ou MariaDB, listar os arquivos já criados e permitir o download com uma rota protegida por autenticação.
Sim, eu poderia ter criado um Job para processar esse backup em segundo plano, colocar tudo bonitinho na fila e deixar o Laravel trabalhar enquanto eu fingia maturidade arquitetural. Mas, desta vez, a ideia foi me desafiar a fazer o fluxo direto pela página, entendendo cada etapa da operação e vendo o backup nascer ali, no clique, igual pão de queijo saindo do forno. Em produção, dependendo do tamanho do banco, um Job pode ser uma escolha melhor; aqui, o objetivo também era aprender apanhando com elegância.
O Filament se encaixa muito bem nesse tipo de solução porque permite criar páginas customizadas dentro do painel. A documentação oficial explica que páginas customizadas são componentes Livewire de tela cheia, com utilitários extras para integração ao painel, navegação, ações e cabeçalho.1 Além disso, ações de cabeçalho podem ser adicionadas por meio do método getHeaderActions(), exatamente como faremos aqui
O que vamos construir
A solução será composta por três partes principais. Primeiro, criaremos uma página no Filament chamada Backup, responsável pela interface administrativa. Depois, criaremos um serviço chamado BackupService, que fica encarregado da regra de negócio: encontrar a conexão do banco, montar o DSN, gerar o arquivo .sql, salvar tudo em storage/app/backups e listar os backups existentes. Por fim, adicionaremos uma rota autenticada para permitir o download do arquivo.
A biblioteca usada para gerar o dump é a ifsnop/mysqldump-php, uma implementação em PHP inspirada no mysqldump do MySQL. Segundo o repositório oficial, ela suporta exportação de estruturas, dados, views, triggers, eventos, routines e opções como extended-insert, complete-insert e hex-blob
Instalando o pacote necessário
Antes de sair apertando botão igual criança em elevador, precisamos instalar o pacote que fará o trabalho pesado:
Reconhece essa biblioteca, comentamos sobre ela neste post: simplificando_backups
composer require ifsnop/mysqldump-php
Esse pacote será chamado pelo nosso serviço. Inclusive, o código já faz uma checagem elegante no construtor: se a classe Mysqldump não existir, ele lança uma exceção explicando o que está faltando. Ou seja, em vez de um erro místico tipo “Class not found” às 2h da manhã, você recebe uma mensagem minimamente humana.
public function __construct()
{
if (! class_exists(Mysqldump::class)) {
throw new Exception('O pacote "ifsnop/mysqldump-php" é necessário. Instale via Composer.');
}
}Criando a página Backup no Filament
A página do Filament é o rosto bonito da operação. Ela aparece no menu, entra no grupo Ferramentas, define ícone, título e view. É basicamente o balcão onde o administrador chega e fala: “moço, me vê um backup completo, por favor”.
php artisan make:filament-page Backup
<?php
namespace App\Filament\Pages;
use App\Services\BackupService;
use Exception;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class Backup extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-circle-stack';
protected static string $view = 'filament.pages.backup';
protected static ?string $navigationLabel = 'Backup';
protected static ?string $title = 'Backup';
protected static ?string $navigationGroup = 'Ferramentas';
public array $backups = [];
public function mount(): void
{
$this->refreshBackups();
}
protected function getHeaderActions(): array
{
return [
Action::make('runBackup')
->label('Executar Backup Agora')
->icon('heroicon-o-play')
->color('success')
->requiresConfirmation()
->modalHeading('Executar Backup')
->modalDescription('Isso irá gerar um dump completo do banco. Deseja continuar?')
->modalSubmitActionLabel('Sim, executar')
->action(function () {
try {
$path = app(BackupService::class)->createBackup();
$this->refreshBackups();
Notification::make()
->title('Backup gerado com sucesso')
->body('Use o botão Baixar na lista abaixo para fazer o download.')
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title('Erro ao gerar backup')
->body($e->getMessage())
->danger()
->send();
}
}),
];
}
private function refreshBackups(): void
{
try {
$this->backups = app(BackupService::class)->listBackups();
} catch (Exception) {
$this->backups = [];
}
}
}Aqui temos algumas decisões importantes. A propriedade $backups é pública porque a view Blade poderá consumir essa lista diretamente. O método mount() chama refreshBackups() assim que a página carrega, garantindo que o administrador veja os arquivos existentes. Já o método getHeaderActions() adiciona um botão no cabeçalho da página, recurso previsto pela própria arquitetura de páginas customizadas do Filament.
O botão que gera o backup: pequeno por fora, intenso por dentro
O coração da página é esta ação:
Action::make('runBackup')
->label('Executar Backup Agora')
->icon('heroicon-o-play')
->color('success')
->requiresConfirmation()
->modalHeading('Executar Backup')
->modalDescription('Isso irá gerar um dump completo do banco. Deseja continuar?')
->modalSubmitActionLabel('Sim, executar')Esse trecho cria uma ação visual com rótulo, ícone, cor e confirmação. A confirmação é essencial porque gerar backup pode ser uma operação pesada, dependendo do tamanho do banco. E também porque todo painel administrativo tem aquele usuário que clica em tudo “só para ver o que acontece”.
Quando o usuário confirma, o código executa:
$path = app(BackupService::class)->createBackup(); $this->refreshBackups();
Essa dupla faz o serviço completo: cria o arquivo e atualiza a lista. Em seguida, a notificação de sucesso informa que o arquivo está pronto para download. Se algo falhar, a exceção é capturada e exibida em uma notificação de erro. Nada de tela branca da morte. Nada de “deu ruim” sem contexto. Apenas civilização.
Criando o BackupService: onde a mágica realmente acontece
Agora entramos na parte que usa capacete. O BackupService é responsável por conversar com a configuração do Laravel, identificar a conexão atual e criar o dump usando Mysqldump.
<?php
namespace App\Services;
use Exception;
use Ifsnop\Mysqldump\Mysqldump;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class BackupService
{
public function __construct()
{
if (! class_exists(Mysqldump::class)) {
throw new Exception('O pacote "ifsnop/mysqldump-php" é necessário. Instale via Composer.');
}
}
public function createBackup(): string
{
$connectionName = config('database.default');
$connection = config("database.connections.{$connectionName}");
if (! in_array($connection['driver'] ?? null, ['mysql', 'mariadb'], true)) {
throw new Exception('O backup automático suporta apenas conexões MySQL/MariaDB.');
}
$directory = $this->backupDirectory();
$filename = 'backup_' . now()->format('Ymd_His') . '.sql';
$path = "{$directory}/{$filename}";
$dump = new Mysqldump(
$this->dsn($connection),
$connection['username'],
$connection['password'],
[
'add-drop-table' => true,
'single-transaction' => true,
'lock-tables' => false,
'routines' => true,
'add-locks' => true,
'extended-insert' => true,
'complete-insert' => true,
'hex-blob' => true,
'skip-triggers' => false,
'no-data' => false,
'no-create-info' => false,
'disable-keys' => true,
]
);
$dump->start($path);
return $path;
}
}A primeira sacada boa aqui é usar config('database.default'). Isso evita deixar a conexão hardcoded. Se amanhã o projeto trocar de conexão padrão, o serviço acompanha a configuração do Laravel, sem precisar sair procurando string escondida em arquivo antigo.
A segunda sacada é limitar o suporte a mysql e mariadb. Isso é importante porque o dump está sendo gerado com uma biblioteca voltada ao ecossistema MySQL. Se alguém estiver usando PostgreSQL, SQLite ou outro banco, o serviço não tenta fingir que está tudo bem. Ele simplesmente avisa: “amigo, aqui é baile de MySQL/MariaDB”.
As opções do dump explicadas sem invocar um DBA ancestral
As configurações passadas para Mysqldump definem como o arquivo .sql será gerado. Algumas delas são especialmente importantes para tornar o backup mais completo, seguro para dados binários e prático em restaurações.
O pacote mysqldump-php documenta várias dessas opções, incluindo routines, extended-insert, complete-insert, hex-blob, single-transaction, lock-tables e disable-keys.3 Em outras palavras, não estamos apenas jogando flags aleatórias no caldeirão. Cada opção tem uma função prática no resultado final.
Criando e validando o diretório de backups
O método backupDirectory() cuida de uma parte que parece simples, mas salva muita dor de cabeça: garantir que o diretório exista e seja gravável.
private function backupDirectory(): string
{
$directory = storage_path('app/backups');
if (! File::exists($directory)) {
File::makeDirectory($directory, 0775, true);
}
if (! is_writable($directory)) {
throw new Exception("O diretório de backups não tem permissão de escrita: {$directory}");
}
return $directory;
}Esse código cria a pasta storage/app/backups caso ela não exista. Depois, verifica se a aplicação consegue escrever nela. Isso é ótimo porque problemas de permissão em servidor são como boleto: se você ignorar, eles voltam maiores.
Se o diretório de backup não tem permissão de escrita, o problema não é o backup. É o servidor fazendo cosplay de cofre sem chave.
Montando o DSN para MySQL, MariaDB e socket Unix
A biblioteca espera uma string DSN compatível com PDO. O serviço monta essa string considerando duas possibilidades: conexão por unix_socket ou conexão tradicional por host e porta.
private function dsn(array $connection): string
{
$charset = $connection['charset'] ?? 'utf8mb4';
$database = $connection['database'];
if (! empty($connection['unix_socket'])) {
return "mysql:unix_socket={$connection['unix_socket']};dbname={$database};charset={$charset}";
}
$host = $connection['host'] ?? '127.0.0.1';
$port = $connection['port'] ?? 3306;
return "mysql:host={$host};port={$port};dbname={$database};charset={$charset}";
}Esse cuidado é útil porque muitos ambientes locais e servidores usam configurações diferentes. Em alguns casos, o banco responde via host e porta. Em outros, especialmente ambientes Unix, o acesso pode acontecer via socket. O serviço está preparado para os dois cenários, sem precisar de ritual ocultista no .env.
Listando backups existentes
Gerar backup é bom. Ver que ele existe é melhor ainda. O método listBackups() percorre o diretório, filtra arquivos .sql, ordena pelos mais recentes e devolve nome, tamanho e data de criação.
public function listBackups(): array
{
$directory = $this->backupDirectory();
return collect(File::files($directory))
->filter(fn ($file) => Str::endsWith($file->getFilename(), '.sql'))
->sortByDesc(fn ($file) => $file->getMTime())
->map(fn ($file) => [
'name' => $file->getFilename(),
'size' => $this->formatBytes($file->getSize()),
'created_at' => date('d/m/Y H:i:s', $file->getMTime()),
])
->values()
->all();
}Esse método transforma arquivos brutos em dados amigáveis para a interface. Em vez de mostrar backup_20260528_153012.sql com tamanho em bytes crus, a tela pode exibir algo como 2.41 MB, porque ninguém merece calcular byte mentalmente enquanto toma café requentado.
A formatação acontece aqui:
private function formatBytes(int $bytes): string
{
foreach (['B', 'KB', 'MB', 'GB'] as $unit) {
if ($bytes < 1024) {
return round($bytes, 2) . " {$unit}";
}
$bytes /= 1024;
}
return round($bytes, 2) . ' TB';
}A rota de download: o porteiro que não deixa estranho entrar
Agora vem uma parte crucial: permitir o download do backup sem abrir uma avenida para problemas de segurança.
Route::get('/backup/download/{file}', function (string $file) {
abort_if($file !== basename($file), 404);
$path = storage_path('app/backups/' . $file);
abort_if(! file_exists($path), 404);
return response()->download($path);
})->name('backup.download')->middleware(['auth']);A linha mais importante aqui é esta:
abort_if($file !== basename($file), 404);
Ela impede que alguém tente passar caminhos maliciosos no parâmetro, como ../../.env, porque compara o valor recebido com seu próprio basename. Se o valor contiver caminho, barras ou tentativa de fuga do diretório, a comparação falha e a aplicação responde com 404. É o equivalente backend de um segurança falando: “seu nome não está na lista”.
Depois disso, o código monta o caminho real dentro de storage/app/backups, verifica se o arquivo existe e retorna response()->download($path). A rota também usa middleware(['auth']), o que significa que só usuários autenticados conseguem acessar o download. Isso é essencial, porque um backup .sql pode conter praticamente todo o DNA da aplicação.
Exemplo de view Blade para listar os backups
Como a página aponta para filament.pages.backup, você pode criar uma view em algo como:
resources/views/filament/pages/backup.blade.php
<x-filament-panels::page>
<div class="space-y-4">
@if (empty($backups))
<div class="rounded-xl border border-dashed border-gray-300 p-6 text-center text-gray-500">
Nenhum backup encontrado. O banco está vivendo perigosamente.
</div>
@else
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
<table class="w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left text-sm font-semibold">Arquivo</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Tamanho</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Criado em</th>
<th class="px-4 py-3 text-right text-sm font-semibold">Ações</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
@foreach ($backups as $backup)
<tr>
<td class="px-4 py-3 text-sm font-medium">
{{ $backup['name'] }}
</td>
<td class="px-4 py-3 text-sm">
{{ $backup['size'] }}
</td>
<td class="px-4 py-3 text-sm">
{{ $backup['created_at'] }}
</td>
<td class="px-4 py-3 text-right text-sm">
<a
href="{{ route('backup.download', $backup['name']) }}"
class="text-primary-600 hover:underline"
>
Baixar
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</x-filament-panels::page>Essa view consome o array $backups definido na página e monta uma tabela simples. O botão Baixar aponta para a rota nomeada backup.download, passando o nome do arquivo. Como a rota valida o nome e exige autenticação, temos uma experiência prática sem transformar o backup em lembrancinha pública de festa.
Se isso fosse um filme, seria um assalto ao banco ao contrário: em vez de tirar dinheiro, você tira uma cópia organizada do banco e guarda para quando o servidor resolver praticar esportes radicais.
Melhorias recomendadas para produção
A solução já resolve muito bem o caso de gerar e baixar backups manualmente, mas em produção vale adicionar alguns temperos extras. Backup é coisa séria; ele só parece chato até o dia em que vira protagonista.
Uma recomendação importante é trocar o middleware simples auth por uma autorização mais específica. Por exemplo, você pode adicionar uma policy, um gate ou checar uma role administrativa. Afinal, estar logado não significa que a pessoa deveria poder baixar o banco inteiro. O estagiário pode ser gente boa, mas talvez ele não precise levar a base de produção para casa.
Cuidados com LGPD e dados sensíveis
Um backup .sql pode conter nomes, e-mails, telefones, documentos, endereços, tokens, logs e outras informações sensíveis. Portanto, trate esse arquivo como um item confidencial. Não envie por grupos de mensagem, não deixe em pasta pública, não jogue no desktop chamado “backup definitivo” e, pelo amor dos dados, não suba no Git.
Regra de ouro: se o backup contém dados reais de usuários, ele merece o mesmo cuidado que o banco original.
Se o ambiente exigir mais segurança, considere criptografar o arquivo antes de armazenar, limitar o tempo de retenção, registrar quem realizou downloads e mover os arquivos para um armazenamento externo seguro.
Conclusão
Com poucas peças bem encaixadas, criamos uma página de backup elegante dentro do FilamentPHP. A página customizada cuida da experiência administrativa, o BackupService concentra a regra de negócio, e a rota autenticada permite baixar os arquivos sem expor o diretório publicamente.
O resultado é aquele tipo de funcionalidade que ninguém valoriza na segunda-feira, mas todo mundo aplaude quando o banco resolve virar purê na sexta às 17h58. E o melhor: com confirmação, notificação, listagem, download e proteção básica contra caminhos maliciosos.
No fim, construir backup é exatamente isso: preparar o guarda-chuva antes da tempestade. Porque quando o servidor começa a tossir, não dá tempo de pesquisar “como recuperar tabela apagada sem backup” e esperar que o Stack Overflow tenha misericórdia.
Código completo organizado
E ai curtiu? Deixe nos comentários se faria de outra forma.

Comentários
Realize login para comentar neste post