No ecossistema WordPress, WP_Query recebe toda a glória, mas WP_User_Query é o que sustenta sites de membros, intranets e plataformas comunitárias.
Esteja a construir uma página simples de “A nossa equipa” ou um motor de busca de “Encontrar um médico” com milhares de profissionais, depender de plugins como Ultimate Member para a camada de apresentação é muitas vezes um exagero e um gargalo de desempenho. Uma consulta leve no template supera mais um plugin de membros que carrega os seus próprios templates, rotas REST e CSS em cada pedido.
Neste guia vamos ignorar a interface gráfica e construir consultas de utilizadores eficientes e seguras diretamente em PHP: filtros com role__in, meta_query e paginação, sem sobrecarregar a base de dados nem expor nomes de login.
Referência oficial: WP_User_Query em developer.wordpress.org. Cada valor por defeito e cada comportamento descrito abaixo foi verificado no código-fonte da classe em wordpress-develop a 12 de outubro de 2026.
get_users() vs. WP_User_Query
Assim como get_posts() é um invólucro para WP_Query, get_users() é um invólucro fino para WP_User_Query. Aceita o mesmo array de argumentos e devolve um array de objetos WP_User (ou subconjuntos de campos quando passa fields).
- Use
get_users()para listas curtas e nada mais, por exemplo cinco editores para um widget de autoria. - Use
WP_User_Queryquando precisa deget_results()maisget_total()para diretórios paginados, de inspecionar$query->requestao depurar SQL ou de um ciclo de vida explícito do objeto de consulta.
Há uma diferença de comportamento que convém conhecer. get_users() força count_total para false antes de construir a consulta, por isso nunca executa a contagem adicional e não tem total para lhe dar. É o valor certo para um widget e o errado para um diretório paginado.
Nas duas APIs o array de argumentos é o contrato. Tudo o que se segue funciona com qualquer das formas; os exemplos usam WP_User_Query porque os diretórios precisam de totais.
O que WP_User_Query devolve com os argumentos por defeito
Os valores por defeito decidem a maior parte do custo de uma consulta descuidada:
| Argumento | Por defeito | O que significa na prática |
|---|---|---|
number | sem limite | São devolvidos todos os utilizadores que coincidem. O docblock pede cautela com -1 (todos) em sites maiores. |
paged | 1 | Só é usado quando number é maior do que zero. |
orderby | login | Ordenação por user_login, raramente o que uma lista pública quer. |
fields | all | Objetos WP_User completos, com todo o user meta pré-carregado através de cache_users(). |
count_total | true | Acrescenta SQL_CALC_FOUND_ROWS e executa uma segunda consulta SELECT FOUND_ROWS(). |
blog_id | site atual | Os filtros de função e de pertença aplicam-se apenas ao site atual. |
cache_results | true | Os resultados ficam na cache de objetos (adicionado no WordPress 6.3). |
Daqui resultam duas consequências. Uma consulta sem number num site com muitos utilizadores carrega-os todos. E uma consulta que nunca pagina deve passar 'count_total' => false, porque o docblock indica que desligá-lo melhora o desempenho quando a paginação não é necessária.
A classe também recusa correr cedo demais: desde o WordPress 6.1.1, uma consulta de utilizadores executada antes do hook plugins_loaded gera um aviso _doing_it_wrong().
Como construir uma página de equipa com WP_User_Query e role__in
Editores e autores, ordenados pelo nome de exibição, doze por página:
$paged = max( 1, absint( $_GET['team_page'] ?? 1 ) );
$args = [
'role__in' => [ 'editor', 'author' ],
'orderby' => 'display_name',
'order' => 'ASC',
'number' => 12,
'paged' => $paged,
'fields' => [ 'ID', 'display_name' ],
];
$user_query = new WP_User_Query( $args );
$results = $user_query->get_results();
if ( ! empty( $results ) ) {
// Uma consulta para o meta dos doze utilizadores em vez de doze consultas.
update_meta_cache( 'user', wp_list_pluck( $results, 'ID' ) );
echo '<div class="team-grid">';
foreach ( $results as $user ) {
$avatar = get_avatar( $user->ID, 128 );
$name = esc_html( $user->display_name );
$bio = esc_html( get_user_meta( $user->ID, 'description', true ) );
echo "<article class='team-member'>
<figure>{$avatar}</figure>
<h3>{$name}</h3>
<p>{$bio}</p>
</article>";
}
echo '</div>';
}Notas que contam em produção:
role__incoincide com qualquer uma das funções listadas. Prefira-o a várias consultas comrole.fieldsdevolve linhasstdClassem vez de objetosWP_Usercompletos, o que poupa memória quando só precisa de IDs e nomes.- Com um array em
fieldso WordPress não pré-carrega o user meta, por isso a chamada aupdate_meta_cache()acima é o que mantém as leituras da biografia numa só consulta. Comfields => 'all'a classe faz esse pré-carregamento através decache_users(). - O número da página vem de um parâmetro próprio,
team_page. Numa página estática a consulta principal usapage, nãopaged, por isso lerget_query_var( 'paged' )aí devolve em silêncio o valor por defeito.
Como funcionam os filtros role, role__in e capability
Os três argumentos de função parecem-se e comportam-se de forma diferente:
roleaceita uma string ou um array, e o array é uma lista AND: o utilizador tem de ter cada função listada.'role' => [ 'editor', 'author' ]quase não devolve ninguém num site normal.role__iné a lista OR: pelo menos uma das funções.role__not_inexclui utilizadores que tenham qualquer uma das funções listadas.
Por baixo, nenhum deles toca numa tabela de funções, porque o WordPress não tem nenhuma. As funções vivem num array serializado na linha de user meta {prefix}capabilities, e cada cláusula de função passa a ser uma comparação LIKE '%"editor"%' sobre essa linha. Um filtro de função já é um join sobre wp_usermeta antes de acrescentar uma única meta_query.
Desde o WordPress 5.9 existem também capability, capability__in e capability__not_in. Traduzem capacidades nas funções que as concedem, mais as capacidades atribuídas diretamente a um utilizador. O docblock avisa que não funcionam com capacidades que não estejam guardadas na base de dados ou que sejam filtradas através de map_meta_cap. Trate-as como um atalho para capacidades primitivas como edit_posts, não como verificação de permissões.
Como paginar WP_User_Query com get_total()
Os argumentos de paginação são number (tamanho da página) e paged (base 1). Depois da consulta, get_total() é a contagem de resultados em todas as páginas, não apenas na atual.
$per_page = 24;
$paged = max( 1, absint( $_GET['udir_page'] ?? 1 ) );
$query = new WP_User_Query(
[
'role__in' => [ 'subscriber', 'contributor' ],
'number' => $per_page,
'paged' => $paged,
'fields' => 'ID',
'orderby' => 'registered',
'order' => 'DESC',
]
);
$total_users = (int) $query->get_total();
$total_pages = (int) ceil( $total_users / $per_page );
$user_ids = $query->get_results();
// Construa as ligações com add_query_arg( 'udir_page', $n ) e escape os URLs.Antipadrões a evitar:
- Pedir
'number' => -1(ou omiti-lo) e cortar comarray_sliceem PHP - Executar uma segunda consulta completa só para contar linhas quando
get_total()já fez o trabalho - Colocar valores não confiáveis de
$_GETemorderbysem uma lista de permissões rígida
Dois detalhes do código-fonte apanham muita gente. Se passar offset, é ele que manda: a classe constrói LIMIT offset, number e ignora paged. E com fields => 'ID' o resultado é um array simples de IDs em forma de string, vindo de $wpdb->get_col(), não objetos, por isso converta-os antes de comparações estritas.
Em diretórios públicos use uma query var dedicada (como acima) para não colidir com o ciclo paged principal do blog.
Como filtrar utilizadores com meta_query
meta_query é onde os diretórios se tornam úteis, e onde aparece o custo do MySQL. Exemplo: subscritores em Lisboa, apenas perfis públicos, com PHP listado num meta de competências.
$args = [
'role' => 'subscriber',
'number' => 20,
'paged' => 1,
'fields' => [ 'ID', 'display_name' ],
'meta_query' => [
'relation' => 'AND',
[
'key' => 'city',
'value' => 'Lisboa',
'compare' => '=',
],
[
'key' => 'is_public_profile',
'value' => '1',
'compare' => '=',
],
[
'key' => 'skills',
'value' => 'PHP',
'compare' => 'LIKE', // funciona com arrays serializados; caro em escala
],
],
];
$directory = new WP_User_Query( $args );Regras práticas para meta_query sobre utilizadores:
- Prefira comparações exatas
=sobre chaves dedicadas (city,is_public_profile) aLIKEsobre blobs serializados. LIKE 'PHP'é uma pesquisa por substring, por isso também apanha uma competência chamadaPHPUnit. É um erro de exatidão, não só de velocidade.- Guarde competências com vários valores como chaves meta booleanas separadas (
skill_php=1) se as filtrar com frequência. Assim AND/OR passam a ser igualdades amigas dos índices. Outra opção é uma linhaskillspor valor, escrita comadd_user_meta( $user_id, 'skills', 'php' ), que admite chaves duplicadas, filtrada depois com'compare' => '='ou'IN'. - Combine
relationcom cuidado. Grupos aninhados são válidos, mas cada cláusula é mais um join sobrewp_usermeta. - Associe sempre os filtros públicos a uma flag de visibilidade para que perfis privados nunca apareçam em listagens sem autenticação.
A razão pela qual as chaves exatas importam está no esquema do núcleo. A tabela wp_usermeta criada por wp-admin/includes/schema.php tem uma chave primária, um índice em user_id e um índice de prefixo em meta_key. Não há índice em meta_value, que é uma coluna longtext. O MySQL consegue limitar o join às linhas de uma chave, mas tem de ler todas essas linhas para comparar valores. Para diretórios muito grandes, considere mover o índice de pesquisa para Elasticsearch (via ElasticPress) ou usar tabelas SQL personalizadas.
Como funcionam search e search_columns em WP_User_Query
O argumento search é fácil de usar mal porque não se comporta como uma caixa de pesquisa.
- Sem curingas é uma correspondência exata.
'search' => 'ana'encontra o utilizador cujo login é exatamenteana. Acrescente*no início, no fim ou em ambos ('*ana*') para obter umLIKEcom%. search_columnsaceitaID,user_login,user_email,user_url,user_nicenameedisplay_name.IDé sempre comparado com=.- Quando
search_columnsestá vazio, o WordPress adivinha: um termo com@pesquisa emuser_email, um termo numérico emuser_logineID, um URL emuser_url, e tudo o resto em login, URL, email, nicename e nome de exibição ao mesmo tempo.
Este último valor por defeito é o perigoso num diretório público. Um visitante que escreva parte de um endereço de email na sua pesquisa consegue confirmar que esse endereço pertence a um membro. Na pesquisa pública, defina sempre as colunas:
$term = sanitize_text_field( wp_unslash( $_GET['q'] ?? '' ) );
$args = [
'role__in' => [ 'subscriber' ],
'search' => '*' . $term . '*',
'search_columns' => [ 'display_name' ],
'number' => 20,
'fields' => [ 'ID', 'display_name' ],
];O filtro user_search_columns permite a um plugin alterar a lista de colunas em todas as pesquisas de utilizadores do site, por isso verifique-o quando os resultados não batem certo.
Como otimizar a performance de WP_User_Query em diretórios grandes
As consultas de utilizadores parecem baratas até o diretório ser público e filtrado.
Como limitar os campos devolvidos (fields)
Por defeito o WordPress carrega mais dados do que uma grelha de cartões precisa. Passe fields cedo:
$args = [
'role' => 'subscriber',
'number' => 100,
'fields' => [ 'ID', 'display_name', 'user_email' ], // Devolve objetos stdClass, não WP_User
];Retire user_email dos templates públicos. Mantenha-o apenas em ferramentas internas atrás de verificações de capacidades.
Mantenha a lista curta por uma segunda razão: quando um array de fields indica mais de três campos, WP_User_Query desliga a sua própria cache de resultados para essa consulta.
Como contar utilizadores sem os carregar
$query = new WP_User_Query(
[
'role' => 'subscriber',
'number' => 1,
'fields' => 'ID',
]
);
$count = $query->get_total();number => 1 mantém o resultado num único ID, enquanto get_total() continua a devolver a contagem completa.
Para totais por função sem filtros existe count_users(). Não é gratuita: não há nenhum contador de funções guardado por trás. Com a estratégia por defeito, time, executa uma consulta agregada sobre todas as linhas {prefix}capabilities com um LIKE por função, e o docblock descreve-a como intensiva em CPU e adequada a cerca de 10^7 utilizadores. Se só precisa do número total de contas, get_user_count() lê uma opção guardada, user_count. Nos sites que wp_is_large_user_count() considera grandes (mais de 10.000 utilizadores por defeito), esse valor é atualizado por um evento cron duas vezes por dia, por isso pode estar desatualizado.
Como fazer cache dos resultados de WP_User_Query
Desde o WordPress 6.3 a classe faz cache por si. Com cache_results em true, guarda os IDs do resultado e o total no grupo de cache de objetos user-queries, com uma chave feita a partir do MD5 do SQL gerado. A entrada é salgada com o carimbo last-changed de users, e o núcleo atualiza esse carimbo em added_user_meta, updated_user_meta, deleted_user_meta, add_user_role, set_user_role e remove_user_role. Qualquer edição de perfil invalida todas as consultas de utilizadores em cache.
Essa cache só sobrevive entre pedidos quando o site tem uma cache de objetos persistente (Redis ou Memcached). Sem ela dura um único carregamento de página.
Se acrescentar uma camada própria por cima, por exemplo um transient com o HTML renderizado, use como chave um hash dos filtros e invalide-o a partir dos mesmos hooks. Não faça cache de HTML com campos condicionados por capacidades para visitantes anónimos. E tire as escritas frequentes de meta do caminho do pedido: um plugin que atualiza um meta de “última visita” em cada página vista mexe no carimbo de users em cada visita e deixa a cache integrada sem nada para servir.
Quando passar um diretório de membros para Elasticsearch
Os joins sobre wp_usermeta não escalam como um motor de pesquisa. Quando os filtros se multiplicam (cidade + especialidade + idioma + disponibilidade) e a audiência é grande, mova a projeção pesquisável para Elasticsearch (ElasticPress ou um índice próprio) ou para uma tabela de diretório dedicada com índices reais. Mantenha WP_User_Query para o carregamento final de uma lista pequena de IDs.
A classe tem um hook exatamente para isto. Devolver um array diferente de null a partir do filtro users_pre_query salta o SQL por completo; o docblock pede a esses filtros que preencham a propriedade total_users para a paginação continuar a funcionar.
Como evitar leituras N+1 de avatar e user meta
Dentro do ciclo, get_avatar() e chamadas repetidas a get_user_meta() multiplicam-se. Pré-carregue o meta da página inteira com update_meta_cache( 'user', $ids ), que lê todas as linhas de meta do conjunto de IDs numa consulta, como no exemplo da página de equipa. Com perfis muito largos, uma linha de diretório desnormalizada escrita ao guardar o perfil sai ainda mais barata.
Quem pode listar utilizadores através da REST API do WordPress
O seu template pode estar fechado enquanto /wp-json/wp/v2/users continua a responder. O que devolve depende do visitante, e as regras estão em WP_REST_Users_Controller, resumidas na referência do endpoint de utilizadores:
- Um visitante sem a capacidade
list_users(por defeito só os administradores a têm) só vê utilizadores que publicaram conteúdo num tipo exposto à REST API. Membros que nunca publicaram ficam fora da lista anónima. - No contexto por defeito,
view, um objeto de utilizador trazid,name,url,description,link,slugeavatar_urls.username,email,roleseregistered_datesó existem no contextoedit, que exigelist_users. - Filtrar por
rolesoucapabilities, ordenar poremailouregistered_datee pedircontext=editdevolvem um erro de autorização semlist_users. - Para esses visitantes, uma pesquisa fica fixada em
ID,user_login,user_nicenameedisplay_name. O parâmetrosearch_columns, adicionado no WordPress 6.8, não consegue alargar essa lista. per_pagevale 10 por defeito com um máximo de 100, e a resposta traz os cabeçalhosX-WP-TotaleX-WP-TotalPages.
O campo slug é o ponto fraco. wp_insert_user() constrói user_nicename a partir do login quando não é indicado nenhum, passando os primeiros 50 caracteres de user_login por sanitize_title(). Na maioria das contas o slug público é, portanto, o login ou algo muito próximo. O mesmo nicename aparece nos URLs dos arquivos de autor.
Dois filtros dão-lhe controlo. rest_user_query recebe os argumentos de WP_User_Query de cada pedido de lista, por isso pode acrescentar aí uma meta_query de visibilidade. Se o site não tem uso público para o endpoint, remova-o apenas para visitantes anónimos, porque o editor de blocos lê utilizadores pela mesma rota quando há editores com sessão iniciada:
add_filter(
'rest_endpoints',
static function ( $endpoints ) {
if ( ! is_user_logged_in() ) {
unset( $endpoints['/wp/v2/users'], $endpoints['/wp/v2/users/(?P<id>[\d]+)'] );
}
return $endpoints;
}
);Como mitigar user enumeration num diretório de membros
Um diretório de membros é uma lista pública de contas. Trate-o como tal.
- Não revele logins: nunca faça
echo $user->user_login. O login é metade do que um atacante precisa para uma tentativa de acesso. Usedisplay_nameou um nome público dedicado. Lembre-se de queuser_nicenamederiva do login por defeito e não é um substituto seguro. - Esconda os emails: não imprima
user_emailexceto quando o visitante está autenticado e autorizado a vê-lo. - Bloqueie arquivos de autor: os arquivos de autor (
/?author=1,/?author=2) continuam a revelar logins em muitos sites. Se o produto não precisa de arquivos de autor públicos, redirecione-os:
add_action(
'template_redirect',
static function () {
if ( is_author() ) {
wp_safe_redirect( home_url( '/' ), 301 );
exit;
}
}
);- Escape a saída: tudo o que sai do PHP passa por
esc_html(),esc_url()eesc_attr()em atributos. - Proteja os diretórios internos com a capacidade
list_users, não com um URL “secreto”. - Limite a pesquisa pública:
search_columnssó comdisplay_name, como acima.
Diretório de membros e dados pessoais segundo o RGPD
Para membros na UE ou no EEE, sim, são dados pessoais. Nomes, fotografias, cidades e competências de pessoas identificáveis são dados pessoais nos termos do artigo 4.º, ponto 1, do Regulamento Geral sobre a Proteção de Dados. O artigo 25.º, n.º 2, acrescenta uma regra que se traduz diretamente nos argumentos da consulta: por defeito, os dados pessoais não devem ser disponibilizados a um número indeterminado de pessoas sem intervenção do titular.
Em código, isso significa:
- As contas novas começam com
is_public_profile=0até o membro dar o seu acordo. - Flags separadas para “mostrar telefone” ou “mostrar email”: um perfil público não é consentimento para publicar contactos.
- Cada consulta pública inclui a cláusula de visibilidade na sua
meta_query, tal como qualquer filtrorest_user_query. - As bases de dados de staging com membros reais são anonimizadas antes de alguém fora da equipa receber uma cópia.
WP_User_Query apenas filtra. Não substitui o consentimento, a finalidade declarada nem a política de eliminação, mas sem a flag de visibilidade nenhuma política de privacidade impede que o perfil apareça.
Plugin de membros ou diretório próprio com WP_User_Query
Os plugins de membros justificam-se no registo, nos níveis pagos e nas mensagens. A listagem pública é um problema menor, e é precisamente a que corre em cada pedido anónimo. Mantenha o plugin para o fluxo de conta e construa a listagem por si quando ela tiver de ser rápida ou muito filtrada.
Se já depende da grelha de um plugin, meça-a antes de a aceitar: abra a listagem com o Query Monitor e confirme que a consulta de utilizadores tem LIMIT, pede só os campos que os cartões mostram e não dispara uma consulta de meta por cartão.
Como estruturar um template de diretório de membros
Um diretório público duradouro tem normalmente quatro camadas:
- Sanitização da entrada: chaves de ordenação numa lista de permissões, página inteira, slug de cidade normalizado.
- Consulta:
role__in/meta_query/fields/number/paged. - Apresentação: cartões apenas com IDs e campos de exibição seguros.
- Invalidação da cache: ligada às chaves meta que afetam a visibilidade.
Mantenha a interface do plugin para o registo de contas e as mensagens, se precisar dessas funções. Mantenha a listagem e a filtragem no seu tema ou num pequeno mu-plugin que controle. É essa separação que mantém o diretório rápido quando o marketing pede mais um filtro no trimestre seguinte.
Como WP_User_Query trata funções em multisite
Numa rede WordPress multisite a tabela de utilizadores é partilhada. WP_User_Query continua a funcionar, mas os filtros de função são por site. Um utilizador que é Editor no site A pode não ter função no site B. Para um diretório de pessoas ao nível da rede:
- Use
blog_idquando tiver de limitar funções a um único site. - Não assuma que
role__in => array( 'author' )significa as mesmas pessoas em cada blog. - Para listas de equipa entre sites, guarde uma flag de meta de rede (por exemplo
wpp_show_in_directory) e consulte-a em vez de usar só funções.
O código-fonte explica porquê. Em multisite, até uma consulta sem argumentos de função acrescenta uma cláusula EXISTS sobre a chave {prefix}capabilities do site atual, por isso só voltam os membros desse site. Passar 'blog_id' => 0 salta as cláusulas de função e de pertença e consulta a rede inteira.
As cópias de staging de multisite cortam muitas vezes utilizadores. Meça o custo da consulta num dump com a cardinalidade de produção, ou o seu plano de meta_query vai parecer correto até ao dia do lançamento.
Como testar o SQL de WP_User_Query com Query Monitor
Antes de fazer merge:
- Ative o Query Monitor em staging.
- Abra o URL do diretório com a combinação de filtros mais pesada que o marketing usa.
- Confirme uma consulta principal de utilizadores, não uma consulta de meta por cartão.
- Guarde
$query->requestnas notas do PR para que a pessoa seguinte detete regressões.
Se o diretório tiver de suportar pesquisa de texto livre em nomes de exibição, use um índice de pesquisa dedicado ou um argumento search bem delimitado em WP_User_Query. Não faça LIKE sobre user_email num endpoint público. Registe os pedidos lentos do diretório com os filtros usados para os poder reproduzir mais tarde.
Precisa de rever um diretório WordPress ou uma consulta de membros antes da produção? Fale com um desenvolvedor WordPress.
O que reter sobre WP_User_Query num diretório de membros
- Use
fieldspara não carregar objetos de utilizador completos em grelhas de cartões, e fique em três campos ou menos para manter a cache integrada. - Pagine com
number+pagede confie emget_total()para as contas de páginas; passecount_total => falsequando não houver paginação. - Prefira chaves meta exatas a
LIKEsobre arrays de competências serializados. - Pré-carregue o meta com
update_meta_cache()antes do ciclo. - Faça cache de listas de IDs ou de markup quando os filtros forem estáveis; invalide ao gravar perfis.
- Proteja a privacidade: só
display_nameno HTML público, sem logins, nicenames nem emails, e sanitize sempre a saída (esc_html). - Verifique
/wp/v2/usersdo ponto de vista de um visitante anónimo. - Meça o SQL (
$query->request) em staging com volume realista de utilizadores e meta antes do lançamento.
Os utilizadores no WordPress são entidades tal como os posts. Consulte-os com a mesma precisão.
Veja os nossos serviços de segurança WordPress.







