
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 que faz o php artisan storage:link
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.
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/publicO 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:
rm public/storage # apagar o antigo link absoluto
php artisan storage:link --relative
ls -l public/
# storage -> ../storage/app/publicSem acesso ao Artisan, o mesmo por SSH, colocando-te dentro da pasta public:
cd public
rm -f storage
ln -s ../storage/app/public storageO 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:
'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.
Causa 2: o Apache não tem autorização para seguir os links simbólicos
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.
<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:
Options +FollowSymLinksDois 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.
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 SymLinksIfOwnerMatchNão apliques 777: é inútil para a leitura e perigoso num alojamento partilhado.
A rota alternativa: servir os ficheiros sem link simbólico
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.
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
- O link existe e aponta para o sítio certo:
ls -l public/deve mostrarstorage -> ../storage/app/public. Um link a vermelho num terminal com cores é um link partido. - O ficheiro está mesmo no disco public:
php artisan tinkere depoisStorage::disk('public')->exists('photo.jpg'). Se devolverfalse, o ficheiro foi gravado noutro disco (localescreve emstorage/app, não emstorage/app/public). - O URL gerado está correto:
Storage::url('photo.jpg')deve dar/storage/photo.jpg, e o URL completo depende deAPP_URLno.env. - 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
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.storage:link recusa-se a sobrepor um link existente («The [public/storage] link already exists»). Apaga-o primeiro (rm public/storage) ou usa --force.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.

