Laravel storage:link: corrigir o erro 403 Forbidden em OVH e Apache

php artisan storage:link cria o link e os ficheiros de storage/app/public respondem 403: as três causas (link absoluto que o servidor não segue, FollowSymLinks desativado, permissões), as correções pela ordem certa e a rota alternativa quando os links simbólicos são proibidos.

Erro 403 Forbidden com o comando storage:link no Laravel
Resposta rápida

Num alojamento partilhado, o link criado por php artisan storage:link aponta para um caminho absoluto que o servidor web não segue, daí o 403. Recria-o em relativo: php artisan storage:link --relative, ou por SSH a partir da pasta public: ln -s ../storage/app/public storage. Se os links simbólicos forem proibidos, serve os ficheiros por uma rota Laravel.

Lançaste php artisan storage:link, o comando respondeu que o link tinha sido criado e, mesmo assim, cada imagem de storage/app/public devolve 403 Forbidden. Em local funciona tudo; o problema aparece num alojamento partilhado, com a OVH à cabeça. Eis o que o comando faz, porque é que o servidor se recusa a servir os ficheiros e as correções pela ordem em que devem ser tentadas.

O Laravel arruma os ficheiros enviados pelos utilizadores em storage/app/public, fora da raiz web, por segurança. Para os tornar acessíveis, não os copia: cria um link simbólico public/storage que aponta para storage/app/public. Quando o navegador pede https://exemple.fr/storage/photo.jpg, o Apache segue o link e serve storage/app/public/photo.jpg.

bash
php artisan storage:link
# The [public/storage] link has been connected to [storage/app/public].

ls -l public/
# storage -> /home/compte/www/mon-projet/storage/app/public

O detalhe que conta está na última linha: por predefinição, o link é absoluto. Contém o caminho completo da pasta storage tal como o PHP a vê no momento do comando.

Porque é que o servidor responde 403

Três causas, da mais frequente à mais rara. A mensagem é a mesma nos três casos; é a configuração do alojamento que as distingue.

Causa 1: um caminho absoluto que o servidor web não vê

Num alojamento partilhado, o PHP e o Apache nem sempre trabalham com o mesmo caminho de raiz. A conta está montada num caminho (por exemplo /homez.123/compte/) que o PHP inscreveu no link, ao passo que o Apache serve o site a partir de um alias (/home/compte/) ou de um ambiente isolado. O link aponta para uma pasta que o Apache não consegue alcançar: recusa, 403.

A correção é criar um link relativo, que não depende de nenhum caminho de raiz. Desde o Laravel 8, o comando fá-lo diretamente:

bash
rm public/storage                      # apagar o antigo link absoluto
php artisan storage:link --relative
ls -l public/
# storage -> ../storage/app/public

Sem acesso ao Artisan, o mesmo por SSH, colocando-te dentro da pasta public:

bash
cd public
rm -f storage
ln -s ../storage/app/public storage

O destino ../storage/app/public é resolvido em relação à localização do link, ou seja public/: sobe um nível e desce depois para storage/app/public. Continua correto seja qual for o caminho de montagem.

Para que os deploys seguintes mantenham esta escolha, declara o link na configuração em vez de contar com o valor por predefinição:

config/filesystems.php
'links' => [
    public_path('storage') => storage_path('app/public'),
],

E chama sempre php artisan storage:link --relative no teu script de deploy, depois do composer install.

O Apache só segue um link se a diretiva Options o autorizar, com FollowSymLinks ou SymLinksIfOwnerMatch. Este segundo valor, frequente nos alojamentos partilhados, exige ainda que o link e o seu destino pertençam ao mesmo utilizador. Se a opção não estiver presente, o pedido acaba num 403.

O ficheiro public/.htaccess fornecido pelo Laravel não ativa FollowSymLinks: limita-se a Options -MultiViews -Indexes e remete para a configuração do servidor. Ora o mod_rewrite exige de qualquer forma que a pasta autorize o seguimento dos links: se os URL limpos do Laravel funcionam, FollowSymLinks ou SymLinksIfOwnerMatch está ativo, e um 403 que persista aponta então para o segundo, com um link que não pertence ao utilizador certo.

public/.htaccess
<IfModule mod_rewrite.c>
    <IfModule mod_negotiation.c>
        Options -MultiViews -Indexes
    </IfModule>

    RewriteEngine On
    …
</IfModule>

Se o servidor não seguir os links, ou se não tiveres a certeza, acrescenta explicitamente, no topo do ficheiro:

public/.htaccess
Options +FollowSymLinks

Dois resultados possíveis: o 403 desaparece, ou o site inteiro passa a 500 Internal Server Error. Neste segundo caso, o alojamento proíbe alterar Options num .htaccess (AllowOverride sem Options): retira a linha e experimenta Options +SymLinksIfOwnerMatch, que às vezes é tolerado. Se nenhum dos dois passar, a solução é a rota alternativa mais abaixo.

Causa 3: permissões insuficientes na cadeia de pastas

Para servir storage/app/public/photo.jpg, o utilizador do Apache tem de conseguir atravessar cada pasta do caminho (permissão de execução) e ler o ficheiro. Uma pasta storage em 700, criada por outro utilizador ou por um deploy feito em root, basta para provocar o 403.

bash
chmod 755 storage storage/app storage/app/public
chmod 644 storage/app/public/*.jpg   # ou find … -type f -exec chmod 644 {} +
chown -R compte:compte storage        # o mesmo utilizador que o link, para SymLinksIfOwnerMatch

Não apliques 777: é inútil para a leitura e perigoso num alojamento partilhado.

Alguns alojamentos desativam a função PHP symlink() (aparece em disable_functions) e não oferecem SSH: torna-se impossível criar o link. O Laravel pode então servir os ficheiros ele próprio, com uma rota que lê o disco public.

routes/web.php
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;

Route::get('/storage/{path}', function (string $path) {
    abort_unless(Storage::disk('public')->exists($path), 404);

    return Storage::disk('public')->response($path);
})->where('path', '.*');

response() devolve o ficheiro com o tipo MIME certo e os cabeçalhos de cache. O URL continua a ser /storage/photo.jpg, portanto Storage::url() e todos os templates existentes continuam a funcionar. O custo: cada imagem passa pelo PHP em vez de ser servida diretamente pelo Apache. Num site pequeno é invisível; num site com muito tráfego, prefere o link simbólico ou um armazenamento externo (S3 ou equivalente).

Verificar que está tudo no sítio

  1. O link existe e aponta para o sítio certo: ls -l public/ deve mostrar storage -> ../storage/app/public. Um link a vermelho num terminal com cores é um link partido.
  2. O ficheiro está mesmo no disco public: php artisan tinker e depois Storage::disk('public')->exists('photo.jpg'). Se devolver false, o ficheiro foi gravado noutro disco (local escreve em storage/app, não em storage/app/public).
  3. O URL gerado está correto: Storage::url('photo.jpg') deve dar /storage/photo.jpg, e o URL completo depende de APP_URL no .env.
  4. O servidor responde 200: curl -I https://exemple.fr/storage/photo.jpg. Um 403 depois das três verificações anteriores aponta para a causa 2.

Porque é que em local corria tudo bem

Na tua máquina, o PHP e o servidor veem o mesmo sistema de ficheiros com o mesmo utilizador, e php artisan serve não passa pelo Apache: o link absoluto funciona, FollowSymLinks não entra em jogo e as permissões são as da tua sessão. Nada assinala o problema antes do primeiro deploy. Dois hábitos evitam-no: sempre --relative, e um script de deploy que recria o link a cada colocação em produção.

Para um servidor que administras tu próprio, a configuração do Apache para o Laravel está detalhada em Servir uma aplicação Laravel com Apache no Debian 13, capítulo da série Instalar o Laravel 13 num servidor Debian 13.

Erros frequentes

Criar o link a partir da pasta errada ln -s ../storage/app/public storage tem de ser lançado a partir de public/. A partir da raiz do projeto, o destino relativo não leva a lado nenhum e o Apache devolve 404 ou 403.
Um link antigo que ficou para trás storage:link recusa-se a sobrepor um link existente («The [public/storage] link already exists»). Apaga-o primeiro (rm public/storage) ou usa --force.
Esquecer o APP_URL Storage::url('photo.jpg') constrói o URL a partir de APP_URL no .env. Um valor que ficou em http://localhost dá links partidos em produção mesmo quando o link simbólico está correto.
Fazer o deploy copiando os ficheiros por FTP Um cliente FTP não transfere um link simbólico: copia uma pasta vazia ou nada. O link tem de ser criado no servidor, por SSH ou por um script.

Laravel

Damien Flandrin Programador web desde 2010, criador da Gekkode e do Email Impact. Cada artigo é testado num projeto real antes de ser publicado. Contacto
Newsletter

Os novos testes, tutoriais e projetos, por e-mail.

Testes reproduzíveis, código versionado, resultados datados. Nunca spam.