Toda tabela nova em um projeto Laravel começa com a mesma pergunta silenciosa: id bigint autoincrement ou algum tipo de UUID? E se for UUID, qual — v4, v7, ULID, ou aquele Sqids que virou moda?
A maioria de nós resolve isso no piloto automático. Alguém do time diz “usa UUID que é mais seguro” (não é), outro diz “usa autoincrement que é mais rápido” (depende), e a decisão fica gravada em pedra na migration desde o dia um. Só que essa escolha tem consequência real: no índice do banco, na URL da sua API, e o ponto que mais gente erra, na segurança da aplicação.
Essa semana caiu no meu radar um artigo do Wendell Adriel comparando UUID, ULID e Sqids de um jeito bem prático, sem o blá-blá-blá teórico de sempre. Vou usar ele como mapa e completar com o que eu já vi dar errado em produção.
Primeiro: o que cada formato realmente é
Esquece a explicação de “string aleatória grande”. Cada formato tem um modelo de geração diferente, e é isso que determina o comportamento dele.
UUIDv4 — 128 bits, dos quais 122 são aleatoriedade pura (o resto é bits de versão/variante fixos pelo padrão). Zero coordenação entre geradores, zero ordem, zero previsibilidade. É o UUID “clássico” que todo mundo já usou.
UUIDv7 — mesma forma de 128 bits e a mesma representação de 36 caracteres com hífens, mas o layout interno muda: 48 bits de timestamp Unix em milissegundos na frente, seguidos de 74 bits de aleatoriedade/monotonicidade. Ou seja: parece um UUID, se comporta como um UUID, mas carrega ordem temporal embutida.
ULID — também 128 bits (48 de timestamp + 80 de aleatoriedade), mas a representação textual é diferente: 26 caracteres em Base32 Crockford, sem hífen, case-insensitive e URL-safe por padrão. Quando dois ULIDs nascem no mesmo milissegundo, a implementação incrementa a parte aleatória para preservar a ordem,isso se chama monotonicidade.
Sqids — e aqui a família muda de espécie. Sqids não gera identificador nenhum do zero. Ele codifica números inteiros que você já tem, de forma determinística e reversível:
use Sqids\Sqids;
$sqids = new Sqids();
$publicId = $sqids->encode([1, 2, 3]); // '86Rf07'
$numbers = $sqids->decode($publicId); // [1, 2, 3]
Não é hash. Não é criptografia. É praticamente um Base62 chique com embaralhamento de alfabeto. Guarda esse detalhe, porque ele é a chave do erro de segurança mais comum.
Ordenação: por que isso importa mais do que parece
| Formato | Ordenável por tempo | Lexicográfico |
|---|---|---|
| UUIDv4 | Não | Não |
| UUIDv7 | Sim | Sim |
| ULID | Sim | Sim |
| Sqids | Não (por padrão) | Depende do número codificado |
Parece detalhe cosmético, mas isso se traduz direto em performance de banco. Um índice B-tree funciona bem quando os valores novos entram perto do final da árvore, e é basicamente o que um bigint autoincrement sempre fez. UUIDv4, por ser 100% aleatório, insere em qualquer ponto da árvore, forçando reorganizações (page splits) e destruindo a localidade de cache do índice.
UUIDv7 e ULID resolvem exatamente isso: como o timestamp vem primeiro, o valor novo é (quase) sempre maior que o anterior. O índice se comporta como se fosse autoincrement, só que com 128 bits de espaço em vez de 64.
Isso não é teoria: é a diferença entre um índice que cresce de forma previsível e um índice que fragmenta com o tempo e exige manutenção. Se sua tabela recebe uns milhares de inserts por dia, talvez você nem note. Se recebe alguns milhões, você vai sentir na régua do P99 de latência de escrita.
use Ramsey\Uuid\Uuid;
// UUIDv4 — aleatório, ruim para índice de alto volume
$id = Uuid::uuid4()->toString();
// UUIDv7 — ordenado por tempo, bom para índice de alto volume
$id = Uuid::uuid7()->toString();
// 0194f21a-7d7b-7333-9f4d-1e6f6c7b6b71
Armazenamento: string bonita ou bytes eficientes?
Aqui tem uma escolha que muita gente nem sabe que existe. Você pode guardar UUID/ULID como:
- String legível:
CHAR(36)para UUID,CHAR(26)para ULID. Fácil de debugar, e, você vê o valor direto noSELECT *e copia e cola sem pensar. - Binário compacto:
BINARY(16)para os dois formatos, já que no fim das contas são 128 bits. Índice menor, cache mais eficiente, mas exige conversão explícita nas bordas da aplicação (ao entrar e ao sair do banco).
Não existe resposta universal. Para a maioria dos projetos Laravel, o custo de debug ganho com a string legível compensa os bytes extras no índice e só vale trocar para binário quando o volume realmente justificar a complexidade adicional.
O ponto que ninguém explica direito: segurança
Aqui está a parte do artigo que eu acho mais importante, e que resumo numa frase: se o acesso a um recurso só é perigoso porque alguém descobriu o ID, o problema não é o formato do ID. É autorização ausente.
Parece óbvio escrito assim, mas na prática esse raciocínio costuma sair torto de três jeitos:
- Achar que Sqids é seguro contra descoberta. Não é, e a própria documentação do Sqids é explícita sobre isso. É reversível por design. Se você usa Sqids pra “esconder” o ID de um pedido e não tem checagem de propriedade na rota, qualquer um decodifica o ID e enumera seus pedidos.
- Achar que ULID é secreto porque “parece aleatório”. Também não é. Ele carrega um timestamp de criação legível e em muitos casos isso é irrelevante (pedidos, eventos, logs), mas se o seu domínio for sensível a vazamento de horário (por exemplo, um sistema onde saber quando algo foi criado revela informação de negócio), isso é uma fuga de dado, não uma feature.
- Tratar qualquer ID não sequencial como controle de acesso. Esse é o mais perigoso porque funciona “na prática” até não funcionar mais. Trocar
/pedidos/1042por/pedidos/0194f21a-...reduz a chance de alguém adivinhar por acaso, mas não substitui umGate::authorize()ou uma policy checando se o usuário logado é dono daquele pedido.
A regra de ouro do artigo, que eu assino embaixo: se você precisa de um token verdadeiramente secreto e reset de senha, link de convite, API key, use random_bytes() puro, não um formato de ID de entidade. ID de entidade serve para identificar, não para autorizar.
Sqids, na prática, é para outra coisa
Onde o Sqids brilha de verdade não é segurança, é UX de URL. Você já tem um bigint autoincrement como chave primária (rápido, compacto, ótimo para joins) e só quer parar de expor /produtos/48291 numa URL pública, trocando por algo mais “amigável” tipo /produtos/86Rf07.
use Sqids\Sqids;
class ProdutoController
{
public function show(string $publicId)
{
$sqids = new Sqids(minLength: 6);
[$id] = $sqids->decode($publicId);
$produto = Produto::findOrFail($id);
// A autorização continua sendo responsabilidade da policy,
// independente do formato do ID na URL
Gate::authorize('view', $produto);
return view('produtos.show', compact('produto'));
}
}
Reparem: o Gate::authorize() está lá do mesmo jeito que estaria com um ID sequencial. O Sqids só resolveu o problema estético, parar de expor volume de vendas ou contagem de registros pela sequência do ID na URL.
Então qual eu uso?
Baseado no que vi funcionar (e não funcionar) em produção, mais o framework do artigo:
- Chave primária interna (joins, foreign keys):
bigint autoincrement, sempre que possível. É o mais compacto, o mais rápido para índice, e ninguém nunca vai expor essa coluna publicamente. - Precisa gerar ID sem round-trip ao banco (ex: idempotência de request, geração no client antes de persistir): UUIDv7. Forma padrão de UUID (compatível com qualquer coluna
UUIDnativa do Postgres/MySQL 8.0+), com bônus de ordenação temporal. - Precisa de algo mais compacto que UUID para URL/log, ainda gerado sem coordenação: ULID.
- Já tem integer, só quer esconder a sequência na URL pública: Sqid, mas nunca esquecendo que ele não é controle de acesso.
O critério não é “qual formato está na moda”. É: o que esse ID precisa fazer, ser rápido no índice, ser gerado sem coordenar com o banco, ser difícil de adivinhar, ser bonito numa URL? Geralmente você só precisa de uma ou duas dessas coisas ao mesmo tempo, e isso já reduz a escolha pela metade.
Não existe “o UUID certo” nem “o ID errado”. Existe o ID certo para o trabalho que ele precisa fazer. UUIDv4 ainda tem seu lugar quando ordenação não importa e você quer aleatoriedade máxima. UUIDv7 deveria ser o seu default hoje em sistemas write-heavy novos, porque resolve o problema histórico do UUIDv4 sem trocar de tipo de coluna. ULID ganha quando tamanho de string importa. E Sqids resolve estética de URL, não segurança.
Da próxima vez que alguém no seu time disser “usa UUID que é mais seguro”, vale a pena parar e perguntar: seguro contra o quê? Porque se a resposta for “contra alguém adivinhar o próximo ID”, o problema que você realmente precisa resolver está na policy da rota, não na migration.







