Atualizado em 19/06/2026
Quase todo desenvolvedor vindo do PHP "puro" comete o mesmo deslize ao programar para WordPress: usar fopen(), file_put_contents() e unlink() diretamente para mexer em arquivos. O código até funciona na sua máquina e no seu servidor — e falha exatamente no cliente que mais importa, porque a propriedade dos arquivos e as permissões variam de uma hospedagem para outra. A resposta do núcleo do WordPress para esse problema é a WP_Filesystem API, uma camada de abstração que decide sozinha se vai escrever direto no disco, via FTP ou via SSH, mantendo o seu código idêntico nos três casos.
O problema: por que não escrever arquivos "no PHP cru"
Em um servidor bem configurado, o processo do PHP (por exemplo, www-data ou um usuário PHP-FPM dedicado) não é o dono dos arquivos do site. Isso é proposital: separa quem executa código de quem possui os arquivos, reduzindo o estrago de uma invasão. As consequências práticas para quem escreve plugins são três:
- Permissão negada. Se o PHP não tem permissão de escrita no diretório,
file_put_contents()simplesmente falha — e o usuário leigo recebe um erro críptico. - Dono errado. Quando o PHP consegue escrever, o arquivo nasce pertencendo ao usuário do PHP, não ao dono do site. Atualizações e backups feitos por FTP passam a falhar, e você cria um arquivo "órfão" que o cliente não consegue gerenciar.
- Risco de segurança. Para "resolver" o problema, muita gente faz
chmod 0777. Isso torna o arquivo gravável por qualquer processo do servidor — incluindo o de outro site comprometido no mesmo host. É uma das causas mais comuns de propagação de malware.
A WP_Filesystem contorna tudo isso oferecendo uma abstração de credenciais: quando não dá para escrever direto, ela usa as credenciais de FTP/SSH (que o dono do site de fato possui) para realizar a operação com a identidade correta. Para você, programador, a interface é sempre a mesma.
Inicializando a API corretamente
A função WP_Filesystem() vive em wp-admin/includes/file.php, que não está carregado em todo contexto (front-end, cron e até parte do AJAX não o incluem). Por isso o padrão de inicialização é sempre o mesmo: garanta o arquivo, chame WP_Filesystem() e só então use a global $wp_filesystem.
<?php
/**
* Garante que a WP_Filesystem esteja pronta e devolve o objeto global.
* Retorna null se não foi possível inicializar (ex.: faltam credenciais).
*/
function meu_plugin_fs() {
global $wp_filesystem;
// 1) Carrega a definição de WP_Filesystem() quando necessário.
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
// 2) Inicializa. Sem credenciais explícitas, tenta o método Direct.
if ( ! WP_Filesystem() ) {
return null; // transporte exige FTP/SSH e não temos credenciais
}
return $wp_filesystem;
}
Armadilha clássica: acessar $wp_filesystem antes de chamar WP_Filesystem(). A global só passa a existir depois da inicialização bem-sucedida. Antes disso ela é null, e qualquer $wp_filesystem->put_contents(...) dispara um fatal error de chamada a método em null.
Escrevendo um arquivo dentro de uploads
O lugar seguro para gravar é o diretório de uploads, obtido por wp_upload_dir() — é o único caminho que o WordPress garante existir e ser gravável em qualquer instalação. Use FS_CHMOD_FILE em vez de cravar 0644: o administrador pode ter redefinido essa constante no wp-config.php para casar com a hospedagem.
<?php
function meu_plugin_salvar_relatorio( $conteudo ) {
$fs = meu_plugin_fs();
if ( ! $fs ) {
return new WP_Error( 'fs_indisponivel', 'WP_Filesystem não inicializou.' );
}
$uploads = wp_upload_dir();
if ( ! empty( $uploads['error'] ) ) {
return new WP_Error( 'uploads_erro', $uploads['error'] );
}
// Subpasta dedicada ao plugin, criada com a permissão certa.
$dir = trailingslashit( $uploads['basedir'] ) . 'meu-plugin';
if ( ! $fs->is_dir( $dir ) ) {
$fs->mkdir( $dir, FS_CHMOD_DIR );
}
$arquivo = trailingslashit( $dir ) . 'relatorio.txt';
// put_contents devolve bool. FS_CHMOD_FILE define a permissão correta.
$ok = $fs->put_contents( $arquivo, $conteudo, FS_CHMOD_FILE );
return $ok ? $arquivo : new WP_Error( 'escrita_falhou', 'Não foi possível gravar.' );
}
Lendo e checando a existência
Para ler, combine exists() (nunca assuma que o arquivo está lá) com get_contents(), que devolve a string do arquivo ou false em caso de erro. Para arquivos linha a linha existe get_contents_array().
<?php
function meu_plugin_ler_relatorio() {
$fs = meu_plugin_fs();
if ( ! $fs ) {
return '';
}
$uploads = wp_upload_dir();
$arquivo = trailingslashit( $uploads['basedir'] ) . 'meu-plugin/relatorio.txt';
if ( ! $fs->exists( $arquivo ) ) {
return ''; // ainda não foi gerado
}
$conteudo = $fs->get_contents( $arquivo );
return ( false === $conteudo ) ? '' : $conteudo;
}
Repare que mesmo a leitura passa pela API. Em transporte FTP/SSH, ler com file_get_contents() nativo pode até funcionar (leitura costuma ser permitida), mas misturar as duas abordagens leva a bugs sutis de caminho e de codificação. Mantenha tudo dentro do $wp_filesystem para um comportamento previsível.
O fluxo de credenciais em uma tela de admin
Quando o transporte não é Direct, o WordPress precisa pedir as credenciais de FTP/SSH ao usuário. A função request_filesystem_credentials() faz esse trabalho pesado: ela tenta usar credenciais já conhecidas e, se faltar algo, imprime o formulário e retorna false. O fluxo correto é sempre o mesmo — e ele só funciona em uma página de admin, porque precisa de uma tela para exibir o formulário e de uma URL para onde reenviar o POST.
<?php
add_action( 'admin_post_meu_plugin_gerar', 'meu_plugin_handler_gerar' );
function meu_plugin_handler_gerar() {
// Sempre cheque capacidade e nonce antes de qualquer escrita.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Sem permissão.' );
}
check_admin_referer( 'meu_plugin_gerar' );
require_once ABSPATH . 'wp-admin/includes/file.php';
// URL para onde o formulário de credenciais deve reenviar o POST.
$url = wp_nonce_url(
admin_url( 'admin-post.php?action=meu_plugin_gerar' ),
'meu_plugin_gerar'
);
// 1ª passada: descobre se precisamos de credenciais.
$creds = request_filesystem_credentials( $url, '', false, false, null );
if ( false === $creds ) {
// O formulário já foi impresso por request_filesystem_credentials(). Pare aqui.
return;
}
// 2ª passada: tenta conectar com as credenciais informadas.
if ( ! WP_Filesystem( $creds ) ) {
// Credenciais erradas: imprime o formulário de novo, agora com erro.
request_filesystem_credentials( $url, '', true, false, null );
return;
}
// A partir daqui $wp_filesystem está pronto para escrever.
meu_plugin_salvar_relatorio( 'Gerado em ' . current_time( 'mysql' ) );
wp_safe_redirect( admin_url( 'admin.php?page=meu-plugin&done=1' ) );
exit;
}
O detalhe que mais confunde: request_filesystem_credentials() é chamado duas vezes. Na primeira, com o quarto parâmetro $error = false, ela apenas verifica e, se necessário, mostra o formulário. Na segunda (após uma tentativa de conexão que falhou), você passa $error = true para reexibir o formulário sinalizando o erro. Esse padrão de "duas passadas" é exatamente o que o próprio instalador de plugins do WordPress usa.
Os métodos do $wp_filesystem e seus equivalentes "crus"
A classe base WP_Filesystem_Base define a interface comum a todos os transportes. Sempre que possível, prefira o método da API ao equivalente nativo — a coluna da direita só funciona em modo Direct.
| WP_Filesystem | PHP "cru" equivalente | Por que preferir a API |
|---|---|---|
$wp_filesystem->exists( $f ) | file_exists() | Resolve o caminho conforme o transporte (raiz FTP ≠ caminho absoluto). |
$wp_filesystem->get_contents( $f ) | file_get_contents() | Leitura consistente mesmo via FTP/SSH. |
$wp_filesystem->put_contents( $f, $d, FS_CHMOD_FILE ) | file_put_contents() + chmod() | Dono e permissões corretos; respeita as constantes do site. |
$wp_filesystem->mkdir( $d, FS_CHMOD_DIR ) | mkdir() | Cria a pasta com a identidade certa e a permissão padrão do WP. |
$wp_filesystem->delete( $f ) | unlink() / rmdir() | Apaga arquivo ou pasta (com $recursive) via qualquer transporte. |
$wp_filesystem->move( $a, $b ) | rename() | Move/renomeia respeitando permissões; tem flag de sobrescrita. |
$wp_filesystem->copy( $a, $b, false, FS_CHMOD_FILE ) | copy() | Copia já aplicando a permissão de destino. |
Métodos de transporte: Direct, FTP e SSH2
Por baixo, a fábrica do WordPress escolhe uma implementação concreta a partir de get_filesystem_method():
- Direct — o PHP é dono dos arquivos e escreve direto no disco. É o caminho mais rápido e o único viável em cron/AJAX sem credenciais. Usado quando um arquivo de teste criado pelo PHP nasce com o mesmo dono do arquivo atual.
- FTP (ext/ftp ou sockets) — usado quando o Direct não está disponível. Exige host, usuário e senha de FTP, normalmente coletados pelo formulário de credenciais.
- SSH2 — quando a extensão
ssh2do PHP está instalada e há chaves/credenciais configuradas. Mais seguro que FTP, porém menos comum em hospedagem compartilhada.
Nunca assuma o método Direct. É o erro de arquitetura mais comum: o plugin funciona em todo ambiente do desenvolvedor (onde o PHP é dono dos arquivos) e falha no cliente. Sempre trate o caso em que WP_Filesystem() retorna false ou em que request_filesystem_credentials() precisa do formulário.
Validando caminhos: evite path traversal
Se qualquer parte do caminho vier de dados do usuário (nome de arquivo, ID, slug), um atacante pode injetar ../ e tentar escapar do diretório de uploads. A defesa é montar o caminho a partir de uma base confiável, normalizar e conferir o prefixo:
<?php
function meu_plugin_caminho_seguro( $nome ) {
$uploads = wp_upload_dir();
$base = wp_normalize_path( trailingslashit( $uploads['basedir'] ) . 'meu-plugin' );
// Remove diretórios do nome enviado e caracteres perigosos.
$nome = sanitize_file_name( wp_basename( $nome ) );
$alvo = wp_normalize_path( trailingslashit( $base ) . $nome );
// Garante que o caminho final ainda está DENTRO da base.
if ( 0 !== strpos( $alvo, trailingslashit( $base ) ) ) {
return new WP_Error( 'path_invalido', 'Caminho fora do diretório permitido.' );
}
return $alvo;
}
A dupla wp_basename() + sanitize_file_name() remove componentes de diretório e neutraliza caracteres problemáticos; a checagem de prefixo com strpos() === 0 fecha a porta para ../. Esse cuidado se conecta diretamente com os princípios de segurança no código WordPress: validar tudo que entra.
Boas práticas
- Carregue o arquivo certo.
require_once ABSPATH . 'wp-admin/includes/file.php'antes deWP_Filesystem()em qualquer contexto fora de telas de admin. - Trate a falha. Considere sempre o caminho em que a API não inicializa; devolva um
WP_Errore mostre uma mensagem clara em vez de deixar quebrar. - Use as constantes.
FS_CHMOD_FILEeFS_CHMOD_DIR, nunca números mágicos como0777. - Escreva só em uploads.
wp_upload_dir()é o destino seguro e gravável; jamais um caminho fixo. - Dispare escrita a partir do admin. É onde o formulário de credenciais pode aparecer; combine com hooks como
admin_post_*, sempre com nonce e checagem de capacidade. - Não misture APIs. Se decidiu usar
WP_Filesystem, faça leitura e escrita por ela — não intercale comfopen()nativo.
Manipulação de arquivos costuma aparecer quando você está construindo um plugin do zero — gerar um CSV de exportação, salvar um cache em disco, escrever um arquivo de configuração. Tratar isso pela WP_Filesystem desde o início é o que separa um plugin que "funciona no meu servidor" de um que funciona em qualquer hospedagem do cliente.
Perguntas frequentes
Por que não usar fopen() ou file_put_contents() direto num plugin?
Porque o usuário que roda o PHP nem sempre é o dono dos arquivos do site. Em muitas hospedagens compartilhadas o WordPress só consegue escrever via FTP/SSH, e arquivos criados pelo PHP nativo podem ficar com dono e permissões errados — quebrando atualizações e, pior, criando arquivos graváveis pelo grupo (porta de entrada para invasão). A WP_Filesystem abstrai isso: o mesmo código funciona em modo Direct, FTP ou SSH2 sem que você mude uma linha.
Preciso sempre chamar request_filesystem_credentials()?
Só quando o método de transporte não for Direct. Em servidores onde o PHP é dono dos arquivos, WP_Filesystem() conecta sozinho e você nunca vê o formulário. Quando o transporte exige FTP/SSH, o WordPress precisa das credenciais; o fluxo correto é chamar request_filesystem_credentials() primeiro, exibir o formulário se ele retornar false e só então inicializar. Pular esse passo faz WP_Filesystem() falhar silenciosamente em parte das hospedagens.
Onde devo gravar arquivos com segurança?
Dentro do diretório de uploads, obtido com wp_upload_dir() — é o único local que o WordPress garante existir e ser gravável. Nunca escreva em caminho fixo como /var/www/... nem monte caminhos com dados do usuário sem validar. Sempre normalize o caminho final (com wp_normalize_path e checagem de prefixo) para evitar path traversal com ../.
Qual a diferença entre FS_CHMOD_FILE e FS_CHMOD_DIR?
São as constantes que o WordPress define com as permissões corretas para o ambiente: FS_CHMOD_FILE para arquivos (tipicamente 0644) e FS_CHMOD_DIR para diretórios (tipicamente 0755). Passe-as em put_contents() e mkdir() em vez de cravar números — o admin pode tê-las redefinido no wp-config.php para casar com a hospedagem.
Posso usar $wp_filesystem em qualquer lugar do código?
Não antes de inicializar. A variável global $wp_filesystem só existe depois de uma chamada bem-sucedida a WP_Filesystem(), que por sua vez depende de wp-admin/includes/file.php estar carregado. Em contexto de front-end ou cron esse arquivo não é incluído automaticamente; faça o require_once antes. Usar $wp_filesystem sem isso gera um fatal de "chamada a método em null".
A WP_Filesystem funciona em requisições AJAX e WP-Cron?
Funciona, mas com ressalvas. Em AJAX/cron não há tela para exibir o formulário de credenciais, então a operação só é viável se o transporte for Direct ou se as credenciais já estiverem disponíveis (definidas em wp-config.php ou guardadas). Por isso operações de escrita pesadas costumam ser disparadas a partir de uma tela de admin, onde request_filesystem_credentials() pode interagir com o usuário.