W ekosystemie WordPressa to WP_Query zgarnia całą chwałę, ale to WP_User_Query rządzi światem serwisów społecznościowych, intranetów i platform członkowskich.
Niezależnie od tego, czy budujesz prostą stronę “Nasz zespół”, czy rozbudowaną wyszukiwarkę “Znajdź lekarza” z tysiącami specjalistów, poleganie na wtyczkach typu Ultimate Member w warstwie prezentacji jest często przesadą i wąskim gardłem wydajnościowym. Lekkie zapytanie w szablonie wygrywa z kolejną wtyczką członkowską, która przy każdym żądaniu ładuje własne szablony, trasy REST i CSS.
W tym poradniku pominiemy interfejsy graficzne i zbudujemy wydajne, bezpieczne zapytania o użytkowników bezpośrednio w PHP. Omówimy granularne filtrowanie, cache’owanie i środki bezpieczeństwa, które zapobiegają wyciekom danych.
Oficjalna dokumentacja: WP_User_Query na developer.wordpress.org. Każdą wartość domyślną i każde zachowanie opisane niżej sprawdziliśmy 12 października 2026 w kodzie źródłowym klasy w repozytorium wordpress-develop.
get_users() a WP_User_Query
Tak jak get_posts() jest nakładką na WP_Query, tak get_users() jest cienką nakładką na WP_User_Query. Przyjmuje tę samą tablicę argumentów i zwraca tablicę obiektów WP_User (albo wybrane pola, gdy podasz fields).
- Używaj
get_users()do krótkich list i niczego więcej, na przykład pięciu redaktorów w widżecie autorów. - Używaj
WP_User_Query, gdy potrzebujeszget_results()razem zget_total()w katalogu z paginacją, podglądu$query->requestprzy debugowaniu SQL albo jawnego cyklu życia obiektu zapytania.
Jedna różnica w działaniu jest ważna. get_users() przed zbudowaniem zapytania ustawia count_total na false, więc nigdy nie wykonuje dodatkowego liczenia i nie ma dla ciebie sumy wyników. Dla widżetu to dobra wartość, dla katalogu z paginacją zła.
W obu przypadkach kontraktem jest tablica argumentów. Wszystko poniżej działa w obu formach; przykłady używają WP_User_Query, bo katalogi potrzebują sum.
Co zwraca WP_User_Query przy domyślnych argumentach
Wartości domyślne decydują o większości kosztu niestarannego zapytania:
| Argument | Domyślnie | Co to znaczy w praktyce |
|---|---|---|
number | bez limitu | Wracają wszyscy pasujący użytkownicy. Docblock prosi o ostrożność z -1 (wszyscy) na większych stronach. |
paged | 1 | Działa tylko wtedy, gdy number jest większe od zera. |
orderby | login | Sortowanie po user_login, rzadko tym, czego chce publiczna lista. |
fields | all | Pełne obiekty WP_User z całym user meta wczytanym przez cache_users(). |
count_total | true | Dodaje SQL_CALC_FOUND_ROWS i wykonuje drugie zapytanie SELECT FOUND_ROWS(). |
blog_id | bieżąca strona | Filtry ról i członkostwa dotyczą tylko bieżącej strony. |
cache_results | true | Wyniki trafiają do cache obiektów (od WordPressa 6.3). |
Wynikają z tego dwie rzeczy. Zapytanie bez number na stronie z dużą liczbą kont wczytuje wszystkich. A zapytanie, które nigdy nie stronicuje, powinno przekazać 'count_total' => false, bo według docblocka wyłączenie liczenia poprawia wydajność, gdy paginacja nie jest potrzebna.
Klasa nie pozwala też uruchomić się za wcześnie: od WordPressa 6.1.1 zapytanie o użytkowników przed hookiem plugins_loaded wywołuje komunikat _doing_it_wrong().
Jak wyświetlić redaktorów i autorów na stronie zespołu
Załóżmy, że chcemy wyświetlić siatkę pracowników (redaktorów i autorów) posortowaną według wyświetlanej nazwy, dwanaście osób na stronę.
$paged = max( 1, absint( $_GET['team_page'] ?? 1 ) );
$args = [
'role__in' => [ 'editor', 'author' ],
'orderby' => 'display_name',
'order' => 'ASC',
'number' => 12, // Limit paginacji
'paged' => $paged,
'fields' => [ 'ID', 'display_name' ],
];
$user_query = new WP_User_Query( $args );
$results = $user_query->get_results();
if ( ! empty( $results ) ) {
// Jedno zapytanie o meta wszystkich dwunastu osób zamiast dwunastu zapytań.
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>';
}Uwagi, które mają znaczenie na produkcji:
role__indopasowuje dowolną z wymienionych ról. To lepsze niż kilka zapytań zrole.fieldszwraca wierszestdClasszamiast pełnych obiektówWP_User, co oszczędza pamięć, gdy potrzebujesz tylko ID i nazw.- Przy tablicy w
fieldsWordPress nie wczytuje user meta z wyprzedzeniem, więc to wywołanieupdate_meta_cache()utrzymuje odczyt biogramów w jednym zapytaniu. Przyfields => 'all'klasa robi to sama przezcache_users(). - Numer strony pochodzi z osobnego parametru
team_page. Na stronie statycznej główne zapytanie używapage, a niepaged, więcget_query_var( 'paged' )po cichu zwróci tam wartość domyślną.
Jak działają filtry role, role__in i capability
Trzy argumenty ról wyglądają podobnie, a działają inaczej:
roleprzyjmuje tekst albo tablicę, a tablica to lista AND: użytkownik musi mieć każdą z ról.'role' => [ 'editor', 'author' ]na typowej stronie nie zwróci prawie nikogo.role__into lista OR: co najmniej jedna z ról.role__not_inwyklucza użytkowników, którzy mają którąkolwiek z ról.
Pod spodem żaden z nich nie dotyka tabeli ról, bo WordPress jej nie ma. Role są zapisane jako zserializowana tablica w wierszu user meta {prefix}capabilities, a każda klauzula roli zamienia się w porównanie LIKE '%"editor"%' na tym wierszu. Filtr roli jest więc złączeniem z wp_usermeta, zanim dodasz pierwszy meta_query.
Od WordPressa 5.9 istnieją też capability, capability__in i capability__not_in. Tłumaczą uprawnienia na role, które je nadają, plus uprawnienia przypisane bezpośrednio użytkownikowi. Docblock ostrzega, że nie działają dla uprawnień, których nie ma w bazie, ani dla filtrowanych przez map_meta_cap. Traktuj je jako skrót dla prostych uprawnień typu edit_posts, nie jako kontrolę dostępu.
Paginacja listy użytkowników w WordPress
Przy dużych katalogach paginacja decyduje o wydajności i UX. Argumenty to number (rozmiar strony) i paged (liczone od 1). Po zapytaniu get_total() zwraca liczbę trafień ze wszystkich stron, nie tylko z bieżącej.
Czego unikać:
- pobierania
'number' => -1(albo pomijanianumber) i cięcia wyników przezarray_slicew PHP, - drugiego pełnego zapytania tylko po to, żeby policzyć wiersze, skoro
get_total()już to zrobiło, - wstawiania niezaufanych wartości z
$_GETdoorderbybez sztywnej listy dozwolonych wartości.
Dwa szczegóły z kodu źródłowego zaskakują wielu. Jeśli podasz offset, to on wygrywa: klasa buduje LIMIT offset, number i ignoruje paged. A przy fields => 'ID' wynikiem jest płaska tablica ID w postaci tekstu z $wpdb->get_col(), nie obiekty, więc rzutuj je przed ścisłym porównaniem.
Jak dodać paginację numeryczną listy użytkowników
Osobny parametr udir_page nie koliduje z paginacją bloga. Lista nie linkuje do archiwów autora, bo te zdradzają login (o tym niżej).
function wppoland_paginated_users_list( $per_page = 20 ) {
$paged = max( 1, absint( $_GET['udir_page'] ?? 1 ) );
$user_query = new WP_User_Query( [
'role' => 'subscriber',
'number' => $per_page,
'paged' => $paged,
'fields' => [ 'ID', 'display_name' ],
] );
$users = $user_query->get_results();
$total_users = (int) $user_query->get_total();
$total_pages = (int) ceil( $total_users / $per_page );
if ( ! empty( $users ) ) {
echo '<ul class="users-list">';
foreach ( $users as $user ) {
echo '<li>';
echo get_avatar( $user->ID, 48 );
echo '<span>' . esc_html( $user->display_name ) . '</span>';
echo '</li>';
}
echo '</ul>';
}
if ( $total_pages > 1 ) {
echo '<nav class="pagination">';
echo paginate_links( [
'base' => esc_url_raw( add_query_arg( 'udir_page', '%#%' ) ),
'format' => '',
'current' => $paged,
'total' => $total_pages,
'prev_text' => '« Poprzednia',
'next_text' => 'Następna »',
] );
echo '</nav>';
}
echo '<p class="total-users">Łącznie: ' . (int) $total_users . ' użytkowników</p>';
}Jak zrobić infinite scroll listy użytkowników przez AJAX
Handler dostępny dla niezalogowanych (nopriv) to publiczny endpoint. Rola przychodzi z zewnątrz, więc musi trafić na listę dozwolonych wartości, inaczej każdy pobierze listę administratorów. E-maili nie zwracamy wcale.
add_action( 'wp_ajax_wppoland_load_more_users', 'wppoland_load_more_users' );
add_action( 'wp_ajax_nopriv_wppoland_load_more_users', 'wppoland_load_more_users' );
function wppoland_load_more_users() {
check_ajax_referer( 'wppoland_users', 'nonce' );
$allowed_roles = [ 'subscriber', 'author' ];
$page = max( 1, absint( $_POST['page'] ?? 1 ) );
$role = sanitize_key( wp_unslash( $_POST['role'] ?? 'subscriber' ) );
if ( ! in_array( $role, $allowed_roles, true ) ) {
wp_send_json_error( null, 400 );
}
$query = new WP_User_Query( [
'role' => $role,
'number' => 12,
'paged' => $page,
'fields' => [ 'ID', 'display_name' ],
'count_total' => false,
] );
foreach ( $query->get_results() as $user ) {
echo '<div class="user-card">';
echo get_avatar( $user->ID, 100 );
echo '<h3>' . esc_html( $user->display_name ) . '</h3>';
echo '</div>';
}
wp_die();
}Nonce dla skryptu przekazujesz przez wp_localize_script() albo wp_add_inline_script() jako wp_create_nonce( 'wppoland_users' ).
Filtrowanie użytkowników po meta_query w WordPress
Tutaj WP_User_Query błyszczy, i tutaj widać koszt MySQL. Wyobraź sobie katalog programistów, w którym szukasz osób, które:
- są z Warszawy,
- mają profil oznaczony jako publiczny,
- mają PHP na liście umiejętności.
$args = [
'role' => 'subscriber',
'number' => 20,
'fields' => [ 'ID', 'display_name' ],
'meta_query' => [
'relation' => 'AND',
[
'key' => 'city',
'value' => 'Warszawa',
'compare' => '=',
],
[
'key' => 'is_public_profile',
'value' => '1',
'compare' => '=',
],
[
'key' => 'skills',
'value' => 'PHP',
'compare' => 'LIKE', // Wolne, ale działa na zserializowanych tablicach
],
],
];Praktyczne zasady dla meta_query na użytkownikach:
- Dokładne porównania
=na osobnych kluczach (city,is_public_profile) wygrywają zLIKEna zserializowanych danych. LIKE 'PHP'szuka podciągu, więc trafi też umiejętnośćPHPUnit. To błąd poprawności, nie tylko wydajności.- Umiejętności, po których często filtrujesz, zapisuj jako osobne klucze logiczne (
skill_php=1). Inna opcja to jeden wierszskillsna wartość, zapisany przezadd_user_meta( $user_id, 'skills', 'php' ), które dopuszcza powtórzone klucze, a potem filtrowany przez'compare' => '='albo'IN'. - Ostrożnie z
relation. Zagnieżdżone grupy są poprawne, ale każda klauzula to kolejne złączenie zwp_usermeta. - Publiczny filtr zawsze łącz z flagą widoczności, żeby prywatne profile nie pojawiały się na liście dla niezalogowanych.
[!WARNING] Alert wydajnościowy: odpytywanie
wp_usermetajest kosztowne. Tabela tworzona przezwp-admin/includes/schema.phpma klucz główny, indeks nauser_idi indeks prefiksowy nameta_key. Nameta_value(kolumnalongtext) indeksu nie ma, więc MySQL musi przeczytać wszystkie wiersze danego klucza, żeby porównać wartości. Przy bardzo dużych katalogach rozważ przeniesienie indeksu wyszukiwania do Elasticsearch (przez ElasticPress) albo własną tabelę SQL.
Wyszukiwarka użytkowników WordPress w PHP
Argument search łatwo użyć źle, bo nie zachowuje się jak pole wyszukiwania.
- Bez gwiazdek to dokładne dopasowanie.
'search' => 'anna'znajdzie użytkownika z loginem dokładnieanna. Gwiazdka na początku, na końcu albo po obu stronach ('*anna*') dajeLIKEz%. search_columnsprzyjmujeID,user_login,user_email,user_url,user_nicenameidisplay_name.IDjest zawsze porównywane przez=.- Gdy
search_columnsjest puste, WordPress zgaduje: fraza z@szuka wuser_email, liczba wuser_loginiID, adres URL wuser_url, a wszystko inne naraz w loginie, URL, e-mailu, nicename i nazwie wyświetlanej.
To ostatnie jest groźne w publicznym katalogu. Ktoś, kto wpisze fragment adresu e-mail, może potwierdzić, że adres należy do członka. W publicznej wyszukiwarce zawsze ustaw kolumny sam i nie pokazuj e-maili w wynikach:
function wppoland_user_search_form() {
$value = sanitize_text_field( wp_unslash( $_GET['user_search'] ?? '' ) );
?>
<form method="get" action="">
<input type="search" name="user_search" placeholder="Szukaj użytkownika..." value="<?php echo esc_attr( $value ); ?>">
<button type="submit">Szukaj</button>
</form>
<?php
}
function wppoland_search_users( $search_term ) {
$query = new WP_User_Query( [
'search' => '*' . sanitize_text_field( $search_term ) . '*',
'search_columns' => [ 'display_name' ],
'number' => 20,
'fields' => [ 'ID', 'display_name' ],
] );
if ( $query->get_results() ) {
echo '<ul class="search-results">';
foreach ( $query->get_results() as $user ) {
echo '<li>' . get_avatar( $user->ID, 32 ) . esc_html( $user->display_name ) . '</li>';
}
echo '</ul>';
} else {
echo '<p>Nie znaleziono użytkowników.</p>';
}
}Filtr user_search_columns pozwala wtyczce zmienić listę kolumn dla każdego wyszukiwania użytkowników na stronie, więc sprawdź go, gdy wyniki się nie zgadzają.
Katalog użytkowników z filtrem roli i miasta
Rola i miasto przychodzą z adresu URL, więc obie wartości przechodzą przez listę dozwolonych. Bez tego ?role=administrator wypisuje administratorów.
function wppoland_advanced_user_directory() {
$roles = [
'subscriber' => 'Członkowie',
'author' => 'Autorzy',
];
$cities = [ 'warszawa', 'krakow', 'gdansk' ];
$role_filter = sanitize_key( wp_unslash( $_GET['role'] ?? '' ) );
$city_filter = sanitize_key( wp_unslash( $_GET['city'] ?? '' ) );
$search = sanitize_text_field( wp_unslash( $_GET['s_user'] ?? '' ) );
$args = [
'role__in' => array_keys( $roles ),
'number' => 12,
'fields' => [ 'ID', 'display_name' ],
'meta_query' => [
[
'key' => 'is_public_profile',
'value' => '1',
],
],
];
if ( isset( $roles[ $role_filter ] ) ) {
$args['role__in'] = [ $role_filter ];
}
if ( in_array( $city_filter, $cities, true ) ) {
$args['meta_query'][] = [
'key' => 'city',
'value' => $city_filter,
];
}
if ( $search ) {
$args['search'] = '*' . $search . '*';
$args['search_columns'] = [ 'display_name' ];
}
$query = new WP_User_Query( $args );
echo '<form method="get" class="user-filters">';
echo '<select name="role"><option value="">Wszystkie role</option>';
foreach ( $roles as $key => $label ) {
echo '<option value="' . esc_attr( $key ) . '"' . selected( $role_filter, $key, false ) . '>' . esc_html( $label ) . '</option>';
}
echo '</select>';
echo '<select name="city"><option value="">Wszystkie miasta</option>';
foreach ( $cities as $city ) {
echo '<option value="' . esc_attr( $city ) . '"' . selected( $city_filter, $city, false ) . '>' . esc_html( ucfirst( $city ) ) . '</option>';
}
echo '</select>';
echo '<input type="search" name="s_user" placeholder="Szukaj..." value="' . esc_attr( $search ) . '">';
echo '<button type="submit">Filtruj</button>';
echo '</form>';
$results = $query->get_results();
if ( $results ) {
update_meta_cache( 'user', wp_list_pluck( $results, 'ID' ) );
echo '<div class="users-grid">';
foreach ( $results as $user ) {
$city = get_user_meta( $user->ID, 'city', true );
echo '<div class="user-card">';
echo get_avatar( $user->ID, 100 );
echo '<h3>' . esc_html( $user->display_name ) . '</h3>';
if ( $city ) {
echo '<p class="city">' . esc_html( ucfirst( $city ) ) . '</p>';
}
echo '</div>';
}
echo '</div>';
} else {
echo '<p>Brak wyników.</p>';
}
}Miasto zapisujesz w meta jako znormalizowany slug (krakow), dzięki czemu filtr to dokładne = zamiast LIKE.
Jak przyspieszyć WP_User_Query
Odpytując użytkowników na stronie o dużym ruchu, musisz oszczędnie gospodarować zasobami bazy danych.
Jak ograniczyć zwracane pola (fields)
Domyślnie WordPress pobiera pełne obiekty użytkowników i wczytuje całe ich user meta. Jeśli potrzebujesz tylko kilku kolumn, powiedz o tym wprost.
$args = [
'role' => 'subscriber',
'number' => 100,
'fields' => [ 'ID', 'display_name', 'user_email' ], // Zwraca obiekty stdClass, nie WP_User
];Wynik: zużycie pamięci RAM wyraźnie spada. user_email zostaw tylko w narzędziach wewnętrznych za sprawdzeniem uprawnień. Listę pól trzymaj krótką także z innego powodu: gdy tablica fields ma więcej niż trzy pola, WP_User_Query wyłącza dla tego zapytania własny cache wyników.
Jak policzyć użytkowników bez ładowania obiektów
Jeśli chcesz tylko pokazać liczbę członków, nie ładuj ich obiektów.
$query = new WP_User_Query( [
'role' => 'subscriber',
'number' => 1, // Jeden wiersz wyniku wystarczy
'fields' => 'ID',
] );
$count = $query->get_total(); // Używa SQL_CALC_FOUND_ROWSDla sum według ról bez filtrów jest count_users(), ale nie jest ono darmowe: nie stoi za nim żaden zapisany licznik ról. W domyślnej strategii time wykonuje jedno zapytanie agregujące po wszystkich wierszach {prefix}capabilities z jednym LIKE na rolę, a docblock opisuje je jako obciążające procesor i odpowiednie dla około 10^7 użytkowników.
$count = count_users();
echo 'Mamy ' . (int) $count['total_users'] . ' użytkowników.';Jeśli potrzebujesz tylko łącznej liczby kont, get_user_count() czyta zapisaną opcję user_count. Na stronach, które wp_is_large_user_count() uznaje za duże (domyślnie powyżej 10 000 użytkowników), tę wartość odświeża zdarzenie cron dwa razy dziennie, więc może się spóźniać.
Wbudowany cache WP_User_Query od WordPressa 6.3
Od wersji 6.3 klasa cache’uje sama. Przy cache_results ustawionym na true zapisuje ID wyników i sumę w grupie cache obiektów user-queries, z kluczem zbudowanym z MD5 wygenerowanego SQL. Wpis jest solony znacznikiem last-changed grupy users, a rdzeń aktualizuje ten znacznik przy added_user_meta, updated_user_meta, deleted_user_meta, add_user_role, set_user_role i remove_user_role. Każda zmiana profilu unieważnia wszystkie zapisane zapytania o użytkowników.
Ten cache przetrwa między żądaniami tylko wtedy, gdy strona ma trwały cache obiektów (Redis albo Memcached). Bez niego żyje przez jedno wczytanie strony.
Cache listy użytkowników przez transients
Jeśli dokładasz własną warstwę, klucz transientu buduj z wersji, którą podbijasz tymi samymi hookami. Kasowanie transientów zapytaniem SQL do wp_options nie działa, gdy transienty siedzą w trwałym cache obiektów, a wersja w kluczu działa zawsze.
function wppoland_get_cached_user_ids( $role = 'subscriber' ) {
if ( ! in_array( $role, [ 'subscriber', 'author', 'editor' ], true ) ) {
return [];
}
$version = (int) get_option( 'wppoland_users_cache_ver', 1 );
$key = 'wppoland_users_' . $role . '_v' . $version;
$ids = get_transient( $key );
if ( false === $ids ) {
$query = new WP_User_Query( [
'role' => $role,
'number' => 500, // Górny limit zamiast -1
'fields' => 'ID',
'count_total' => false,
] );
$ids = array_map( 'intval', $query->get_results() );
set_transient( $key, $ids, HOUR_IN_SECONDS );
}
return $ids;
}
function wppoland_bump_users_cache() {
update_option( 'wppoland_users_cache_ver', (int) get_option( 'wppoland_users_cache_ver', 1 ) + 1, false );
}
foreach ( [ 'user_register', 'profile_update', 'deleted_user', 'set_user_role', 'updated_user_meta' ] as $hook ) {
add_action( $hook, 'wppoland_bump_users_cache' );
}Stare wersje transientów wygasają same po godzinie. Nie cache’uj HTML z polami widocznymi tylko dla uprawnionych, jeśli ten sam HTML trafia do anonimowych gości. I trzymaj częste zapisy meta z dala od ścieżki żądania: wtyczka, która przy każdej odsłonie aktualizuje meta “ostatnio widziany”, przesuwa znacznik users przy każdym wejściu i zostawia wbudowany cache bez niczego do podania.
Kiedy przenieść katalog członków do Elasticsearch
Złączenia z wp_usermeta nie skalują się jak wyszukiwarka. Gdy filtrów przybywa (miasto, specjalizacja, język, dostępność), a odbiorców jest wielu, przenieś przeszukiwaną projekcję do Elasticsearch (ElasticPress albo własny indeks) lub do osobnej tabeli katalogu z prawdziwymi indeksami. WP_User_Query zostaw do końcowego wczytania krótkiej listy ID.
Klasa ma do tego hook. Gdy filtr users_pre_query zwróci tablicę zamiast null, SQL w ogóle się nie wykona; docblock prosi, by taki filtr ustawił właściwość total_users, żeby paginacja dalej działała.
Jak uniknąć zapytań N+1 o avatary i user meta
W pętli get_avatar() i kolejne wywołania get_user_meta() się mnożą. Wczytaj meta całej strony przez update_meta_cache( 'user', $ids ), które pobiera wszystkie wiersze meta dla zbioru ID jednym zapytaniem, jak w przykładzie strony zespołu. Przy bardzo rozbudowanych profilach jeszcze taniej wychodzi zdenormalizowany wiersz katalogu zapisywany przy zapisie profilu.
Kto może listować użytkowników przez REST API WordPressa
Twój szablon może być szczelny, a /wp-json/wp/v2/users nadal odpowiada. To, co zwraca, zależy od odwiedzającego. Reguły są w WP_REST_Users_Controller, a ich podsumowanie w dokumentacji endpointu users:
- Odwiedzający bez uprawnienia
list_users(domyślnie mają je tylko administratorzy) widzi wyłącznie użytkowników, którzy opublikowali treść w typie udostępnionym w REST API. Członkowie, którzy niczego nie opublikowali, nie trafiają na anonimową listę. - W domyślnym kontekście
viewobiekt użytkownika zawieraid,name,url,description,link,slugiavatar_urls.username,email,rolesiregistered_datesą tylko w kontekścieedit, który wymagalist_users. - Filtrowanie po
roleslubcapabilities, sortowanie poemaillubregistered_dateicontext=editbezlist_userskończą się błędem autoryzacji. - Dla takich odwiedzających wyszukiwanie jest zawężone do
ID,user_login,user_nicenameidisplay_name. Parametrsearch_columns, dodany w WordPressie 6.8, nie poszerzy tej listy. per_pagedomyślnie wynosi 10, maksymalnie 100, a odpowiedź ma nagłówkiX-WP-TotaliX-WP-TotalPages.
Słabym punktem jest pole slug. wp_insert_user() buduje user_nicename z loginu, gdy nicename nie podano: pierwsze 50 znaków user_login przechodzi przez sanitize_title(). U większości kont publiczny slug to więc login albo coś bardzo bliskiego. Ten sam nicename widać w adresach archiwów autora.
Dwa filtry dają kontrolę. rest_user_query dostaje argumenty WP_User_Query dla każdego żądania listy, więc możesz tam dodać meta_query z flagą widoczności. Jeśli strona nie potrzebuje publicznie tego endpointu, wyłącz go tylko dla niezalogowanych, bo edytor blokowy czyta użytkowników tą samą trasą dla zalogowanych redaktorów:
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;
}
);Własny endpoint REST API z listą użytkowników
Od WordPressa 5.5 trasa bez permission_callback wywołuje komunikat _doing_it_wrong(), więc publiczny endpoint deklaruje to jawnie przez __return_true. Rola ma listę dozwolonych wartości, limit ma górną granicę, a odpowiedź nie zawiera e-maila, loginu ani adresu archiwum autora.
add_action( 'rest_api_init', function () {
register_rest_route( 'wppoland/v1', '/users', [
'methods' => 'GET',
'callback' => 'wppoland_get_users_api',
'permission_callback' => '__return_true',
'args' => [
'role' => [
'default' => 'subscriber',
'enum' => [ 'subscriber', 'author' ],
],
'limit' => [
'default' => 10,
'sanitize_callback' => 'absint',
'validate_callback' => static function ( $value ) {
return $value >= 1 && $value <= 50;
},
],
'page' => [
'default' => 1,
'sanitize_callback' => 'absint',
],
],
] );
} );
function wppoland_get_users_api( WP_REST_Request $request ) {
$query = new WP_User_Query( [
'role' => $request->get_param( 'role' ),
'number' => $request->get_param( 'limit' ),
'paged' => max( 1, $request->get_param( 'page' ) ),
'fields' => [ 'ID', 'display_name' ],
'meta_query' => [
[ 'key' => 'is_public_profile', 'value' => '1' ],
],
] );
$users = [];
foreach ( $query->get_results() as $user ) {
$users[] = [
'id' => (int) $user->ID,
'name' => $user->display_name,
'avatar_url' => get_avatar_url( $user->ID ),
];
}
return rest_ensure_response( [
'total' => (int) $query->get_total(),
'users' => $users,
] );
}Jak zablokować user enumeration w WordPress
Domyślnie WordPress jest dość “dziurawy”, jeśli chodzi o dane użytkowników.
- Nie ujawniaj loginów: nigdy nie wykonuj
echo $user->user_login. To połowa klucza potrzebnego do przejęcia konta administratora. Używajdisplay_name.user_nicenamedomyślnie powstaje z loginu, więc nie jest bezpiecznym zamiennikiem. - Ukrywaj e-maile: o ile to nie jest wewnętrzny intranet za logowaniem, nigdy nie wypisuj
user_emailw kodzie HTML, żeby nie karmić botów spamujących. - Escapuj wszystko, co wychodzi z PHP:
esc_html(),esc_url(),esc_attr()w atrybutach. - Katalogi tylko dla administracji zabezpieczaj uprawnieniem
list_users, a nie “tajnym” adresem URL. - Publiczną wyszukiwarkę ogranicz do
search_columnszdisplay_name.
Blokowanie archiwów autora
Boty często skanują /?author=1, /?author=2, żeby poznać nazwy użytkowników. Jeśli budujesz stronę firmową, na której użytkownicy nie potrzebują publicznych archiwów, wyłącz tę trasę.
// Dodaj do functions.php albo mu-plugina
add_action( 'template_redirect', static function () {
if ( is_author() ) {
wp_safe_redirect( home_url( '/' ), 301 );
exit;
}
} );Katalog członków a dane osobowe w RODO
Dla członków z UE i EOG to dane osobowe. Imiona, zdjęcia, miasta i umiejętności osób, które da się zidentyfikować, są danymi osobowymi w rozumieniu art. 4 pkt 1 RODO. Art. 25 ust. 2 dokłada regułę, którą da się przełożyć wprost na argumenty zapytania: domyślnie dane osobowe nie mogą być udostępniane bez interwencji danej osoby nieokreślonej liczbie osób.
W kodzie oznacza to:
- nowe konta startują z
is_public_profile=0, dopóki członek sam tego nie zmieni, - osobne flagi “pokaż telefon” i “pokaż e-mail”: publiczny profil nie jest zgodą na publikację danych kontaktowych,
- każde publiczne zapytanie ma klauzulę widoczności w
meta_query, tak samo każdy filtrrest_user_query, - bazy stagingowe z prawdziwymi członkami są anonimizowane, zanim ktokolwiek spoza zespołu dostanie kopię.
WP_User_Query tylko filtruje. Nie zastępuje zgody, określonego celu ani zasad usuwania danych, ale bez flagi widoczności żadna polityka prywatności nie powstrzyma profilu przed pojawieniem się na liście.
Wtyczka członkowska czy własny katalog z WP_User_Query
Wtyczki członkowskie mają sens przy rejestracji, płatnych poziomach i wiadomościach. Publiczna lista to mniejszy problem, a zarazem ten, który działa przy każdym anonimowym żądaniu. Zostaw wtyczkę do obsługi kont, a listę zbuduj sam, gdy ma być szybka albo mocno filtrowana.
Jeśli już korzystasz z siatki wtyczki, zmierz ją, zanim ją zaakceptujesz: otwórz listę z Query Monitor i sprawdź, czy zapytanie o użytkowników ma LIMIT, pobiera tylko pola pokazywane na kartach i nie odpala osobnego zapytania o meta dla każdej karty.
Filtrowanie według niestandardowych ról
WordPress pozwala tworzyć własne role. Rolę zapisuje się w bazie, więc rejestruje się ją raz, przy aktywacji wtyczki, a nie przy każdym init. WP_Roles::add_role() i tak zwraca null, gdy rola już istnieje, ale nie ma sensu wołać jej przy każdym żądaniu.
// W głównym pliku wtyczki
register_activation_hook( __FILE__, 'wppoland_register_custom_roles' );
function wppoland_register_custom_roles() {
add_role( 'premium_member', 'Członek Premium', [
'read' => true,
] );
add_role( 'instructor', 'Instruktor', [
'read' => true,
'edit_posts' => true,
'delete_posts' => true,
'upload_files' => true,
] );
}
// Wyświetl wszystkich instruktorów
function wppoland_list_instructors() {
$query = new WP_User_Query( [
'role' => 'instructor',
'orderby' => 'display_name',
'order' => 'ASC',
'number' => 50,
] );
if ( $query->get_results() ) {
echo '<div class="instructors-grid">';
foreach ( $query->get_results() as $user ) {
$specialty = get_user_meta( $user->ID, 'specialty', true );
echo '<div class="instructor-card">';
echo get_avatar( $user->ID, 150 );
echo '<h3>' . esc_html( $user->display_name ) . '</h3>';
echo '<p class="specialty">Specjalizacja: ' . esc_html( $specialty ) . '</p>';
echo '</div>';
}
echo '</div>';
}
}Tu fields zostaje domyślne (all), więc klasa sama wczytuje meta wszystkich instruktorów jednym zapytaniem i get_user_meta() w pętli nie odpytuje bazy ponownie.
Lista klientów WooCommerce z WP_User_Query
Wersja, która dla każdego klienta pobiera wszystkie zamówienia przez wc_get_orders( [ 'limit' => -1 ] ), robi zapytanie N+1 i ładuje całe obiekty zamówień. WooCommerce ma do tego funkcje wc_get_customer_order_count() i wc_get_customer_total_spent() (plik wc-user-functions.php). Lista dotyczy roli customer; administratorzy nie są klientami. To widok dla zespołu, więc jest za uprawnieniem.
function wppoland_list_woocommerce_customers() {
if ( ! current_user_can( 'list_users' ) ) {
return;
}
$query = new WP_User_Query( [
'role' => 'customer',
'number' => 50,
'paged' => max( 1, absint( $_GET['customers_page'] ?? 1 ) ),
'fields' => [ 'ID', 'display_name', 'user_email' ],
] );
if ( $query->get_results() ) {
echo '<table class="customers-table">';
echo '<thead><tr><th>Klient</th><th>Email</th><th>Liczba zamówień</th><th>Suma wydana</th></tr></thead>';
echo '<tbody>';
foreach ( $query->get_results() as $user ) {
echo '<tr>';
echo '<td>' . esc_html( $user->display_name ) . '</td>';
echo '<td><a href="mailto:' . esc_attr( $user->user_email ) . '">' . esc_html( $user->user_email ) . '</a></td>';
echo '<td>' . (int) wc_get_customer_order_count( $user->ID ) . '</td>';
echo '<td>' . wc_price( (float) wc_get_customer_total_spent( $user->ID ) ) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
}
}Shortcode z listą użytkowników WordPress
Nazwa shortcode’u jest w ASCII, bo tak wpisuje się ją najpewniej w edytorze. Atrybut role ma listę dozwolonych wartości, a role nie są wypisywane publicznie: etykieta “administrator” na karcie podpowiada, które konto atakować.
add_shortcode( 'lista_uzytkownikow', 'wppoland_users_shortcode' );
function wppoland_users_shortcode( $atts ) {
$atts = shortcode_atts( [
'role' => 'subscriber',
'limit' => 10,
'columns' => 3,
'avatar' => 'yes',
'bio' => 'no',
], $atts );
$role = in_array( $atts['role'], [ 'subscriber', 'author', 'editor' ], true ) ? $atts['role'] : 'subscriber';
$query = new WP_User_Query( [
'role' => $role,
'number' => min( 50, absint( $atts['limit'] ) ),
'fields' => [ 'ID', 'display_name' ],
'count_total' => false,
] );
$results = $query->get_results();
if ( ! $results ) {
return '<p>Brak użytkowników.</p>';
}
if ( 'yes' === $atts['bio'] ) {
update_meta_cache( 'user', wp_list_pluck( $results, 'ID' ) );
}
$output = '<div class="wppoland-users-shortcode" style="display: grid; grid-template-columns: repeat(' . absint( $atts['columns'] ) . ', 1fr); gap: 20px;">';
foreach ( $results as $user ) {
$output .= '<div class="user-item">';
if ( 'yes' === $atts['avatar'] ) {
$output .= '<div class="user-avatar">' . get_avatar( $user->ID, 80 ) . '</div>';
}
$output .= '<h4 class="user-name">' . esc_html( $user->display_name ) . '</h4>';
if ( 'yes' === $atts['bio'] ) {
$bio = get_user_meta( $user->ID, 'description', true );
if ( $bio ) {
$output .= '<p class="user-bio">' . esc_html( $bio ) . '</p>';
}
}
$output .= '</div>';
}
return $output . '</div>';
}Użycie w treści: [lista_uzytkownikow role="author" limit="6" bio="yes"].
Widget z listą użytkowników w WordPress
Klasyczny widget nadal działa w motywach, które korzystają z widżetów. W poprzedniej wersji tego przykładu konstruktor miał błąd składni; poprawne wywołanie to parent::__construct().
class WPPoland_Users_Widget extends WP_Widget {
public function __construct() {
parent::__construct(
'wppoland_users_widget',
'WPPoland: Lista użytkowników',
[ 'description' => 'Wyświetla listę użytkowników z wybraną rolą' ]
);
}
public function widget( $args, $instance ) {
$allowed = [ 'subscriber', 'author', 'editor' ];
$title = ! empty( $instance['title'] ) ? $instance['title'] : 'Użytkownicy';
$role = in_array( $instance['role'] ?? '', $allowed, true ) ? $instance['role'] : 'subscriber';
$limit = ! empty( $instance['limit'] ) ? min( 50, absint( $instance['limit'] ) ) : 5;
echo $args['before_widget'];
echo $args['before_title'] . esc_html( $title ) . $args['after_title'];
$query = new WP_User_Query( [
'role' => $role,
'number' => $limit,
'orderby' => 'registered',
'order' => 'DESC',
'fields' => [ 'ID', 'display_name' ],
'count_total' => false,
] );
if ( $query->get_results() ) {
echo '<ul class="users-widget-list">';
foreach ( $query->get_results() as $user ) {
echo '<li>' . get_avatar( $user->ID, 40 ) . '<span>' . esc_html( $user->display_name ) . '</span></li>';
}
echo '</ul>';
}
echo $args['after_widget'];
}
public function form( $instance ) {
$title = $instance['title'] ?? '';
$role = $instance['role'] ?? 'subscriber';
$limit = ! empty( $instance['limit'] ) ? absint( $instance['limit'] ) : 5;
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">Tytuł:</label>
<input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
type="text" value="<?php echo esc_attr( $title ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'role' ) ); ?>">Rola:</label>
<select class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'role' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'role' ) ); ?>">
<?php foreach ( [ 'subscriber' => 'Subskrybent', 'author' => 'Autor', 'editor' => 'Redaktor' ] as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $role, $key ); ?>><?php echo esc_html( $label ); ?></option>
<?php endforeach; ?>
</select>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'limit' ) ); ?>">Liczba użytkowników:</label>
<input class="tiny-text" id="<?php echo esc_attr( $this->get_field_id( 'limit' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'limit' ) ); ?>"
type="number" min="1" max="50" value="<?php echo esc_attr( $limit ); ?>">
</p>
<?php
}
public function update( $new_instance, $old_instance ) {
return [
'title' => sanitize_text_field( $new_instance['title'] ?? '' ),
'role' => sanitize_key( $new_instance['role'] ?? 'subscriber' ),
'limit' => absint( $new_instance['limit'] ?? 5 ),
];
}
}
add_action( 'widgets_init', static function () {
register_widget( 'WPPoland_Users_Widget' );
} );Szablon katalogu członków w WordPress
Trwały publiczny katalog ma zwykle cztery warstwy:
- Sanityzacja wejścia: klucze sortowania z listy dozwolonych, numer strony jako liczba całkowita, znormalizowany slug miasta.
- Zapytanie:
role__in/meta_query/fields/number/paged. - Prezentacja: karty tylko z ID i bezpiecznych pól do wyświetlenia.
- Unieważnianie cache: powiązane z kluczami meta, które decydują o widoczności.
Interfejs wtyczki zostaw do rejestracji kont i wiadomości, jeśli ich potrzebujesz. Listę i filtrowanie trzymaj w motywie albo w małym mu-pluginie, nad którym masz kontrolę. Ten podział utrzymuje katalog szybkim, gdy marketing w kolejnym kwartale doda następny filtr.
WP_User_Query w multisite i role per strona
W sieci WordPress multisite tabela użytkowników jest wspólna. WP_User_Query dalej działa, ale filtry ról dotyczą jednej strony. Użytkownik, który jest redaktorem na stronie A, może nie mieć żadnej roli na stronie B. Dla katalogu osób z całej sieci:
- ustaw
blog_id, gdy role mają dotyczyć jednej strony, - nie zakładaj, że
role__in => array( 'author' )oznacza tych samych ludzi na każdym blogu, - dla list pracowników z wielu stron zapisz flagę w meta sieci (na przykład
wpp_show_in_directory) i odpytuj ją zamiast samych ról.
Kod źródłowy wyjaśnia dlaczego. W multisite nawet zapytanie bez argumentów ról dodaje klauzulę EXISTS na kluczu {prefix}capabilities bieżącej strony, więc wracają tylko jej członkowie. 'blog_id' => 0 pomija klauzule ról i członkostwa i odpytuje całą sieć.
Kopie stagingowe multisite często mają przyciętą tabelę użytkowników. Mierz koszt zapytania na zrzucie o liczebności zbliżonej do produkcji, inaczej plan meta_query będzie wyglądał dobrze aż do dnia startu.
Jak przetestować SQL WP_User_Query w Query Monitor
Przed scaleniem zmian:
- Włącz Query Monitor na stagingu.
- Otwórz adres katalogu z najcięższą kombinacją filtrów, jakiej używa marketing.
- Sprawdź, że jest jedno główne zapytanie o użytkowników, a nie osobne zapytanie o meta dla każdej karty.
- Zapisz
$query->requestw opisie PR, żeby kolejna osoba zauważyła regresję.
Jeśli katalog ma obsługiwać wyszukiwanie pełnotekstowe po nazwach, użyj osobnego indeksu albo wąsko ustawionego argumentu search. Nie rób LIKE po user_email na publicznym endpoincie. Wolne żądania katalogu loguj razem z filtrami, żeby dało się je później odtworzyć.
Najczęstsze pytania o listę użytkowników w WordPress
Jak wyświetlić tylko użytkowników z konkretną rolą
$users = get_users( [
'role' => 'administrator', // lub inna rola
'fields' => [ 'ID', 'display_name' ],
] );Jak pokazać tylko użytkowników z własnym zdjęciem
Filtrowanie wyników przez get_avatar_url() nie działa: funkcja zawsze zwraca adres, a gdy użytkownik nie ma Gravatara, jest to obrazek domyślny. Pewniejszy sposób to zapisywać ID własnego zdjęcia profilowego w meta i odpytywać, czy klucz istnieje.
$query = new WP_User_Query( [
'role' => 'subscriber',
'number' => 20,
'fields' => [ 'ID', 'display_name' ],
'meta_query' => [
[ 'key' => 'profile_photo_id', 'compare' => 'EXISTS' ],
],
] );Jak sortować użytkowników według daty rejestracji
$args = [
'role' => 'subscriber',
'orderby' => 'registered',
'order' => 'DESC', // najnowsi pierwsi
];Jak wykluczyć użytkowników z listy
$args = [
'role__not_in' => [ 'administrator', 'editor' ],
'exclude' => [ 1, 2, 3 ], // ID do wykluczenia
];Uwaga z kodu źródłowego: gdy podasz jednocześnie include, exclude jest ignorowane.
Jak pobrać tylko liczbę użytkowników
$query = new WP_User_Query( [
'role' => 'subscriber',
'number' => 1,
'fields' => 'ID',
] );
$count = $query->get_total();Jak wyświetlić użytkowników online
Wersja, która pobiera wszystkich użytkowników i filtruje ich w PHP, a do tego zapisuje meta przy każdej odsłonie, obciąża bazę dwa razy. Zapis przy każdym wejściu przesuwa też znacznik users, czyli unieważnia wbudowany cache wszystkich zapytań o użytkowników. Lepiej filtrować w SQL i zapisywać aktywność najwyżej raz na pięć minut.
function wppoland_get_online_users() {
return get_users( [
'number' => 50,
'fields' => [ 'ID', 'display_name' ],
'meta_query' => [
[
'key' => 'last_activity',
'value' => time() - 10 * MINUTE_IN_SECONDS,
'compare' => '>',
'type' => 'NUMERIC',
],
],
] );
}
add_action( 'init', static function () {
if ( ! is_user_logged_in() ) {
return;
}
$user_id = get_current_user_id();
$last = (int) get_user_meta( $user_id, 'last_activity', true );
if ( time() - $last > 5 * MINUTE_IN_SECONDS ) {
update_user_meta( $user_id, 'last_activity', time() );
}
} );Jak dodać własne pola do profilu użytkownika
add_action( 'show_user_profile', 'wppoland_extra_profile_fields' );
add_action( 'edit_user_profile', 'wppoland_extra_profile_fields' );
function wppoland_extra_profile_fields( $user ) {
?>
<h3>Dodatkowe informacje</h3>
<table class="form-table">
<tr>
<th><label for="city">Miasto</label></th>
<td>
<input type="text" name="city" id="city" value="<?php echo esc_attr( get_user_meta( $user->ID, 'city', true ) ); ?>" class="regular-text">
</td>
</tr>
<tr>
<th><label for="phone">Telefon</label></th>
<td>
<input type="text" name="phone" id="phone" value="<?php echo esc_attr( get_user_meta( $user->ID, 'phone', true ) ); ?>" class="regular-text">
</td>
</tr>
</table>
<?php
}
add_action( 'personal_options_update', 'wppoland_save_extra_profile_fields' );
add_action( 'edit_user_profile_update', 'wppoland_save_extra_profile_fields' );
function wppoland_save_extra_profile_fields( $user_id ) {
if ( ! current_user_can( 'edit_user', $user_id ) ) {
return;
}
if ( isset( $_POST['city'] ) ) {
update_user_meta( $user_id, 'city', sanitize_title( wp_unslash( $_POST['city'] ) ) );
}
if ( isset( $_POST['phone'] ) ) {
update_user_meta( $user_id, 'phone', sanitize_text_field( wp_unslash( $_POST['phone'] ) ) );
}
}sanitize_title() zapisuje miasto jako slug (krakow), dzięki czemu filtr w katalogu może używać dokładnego =.
Podsumowanie
| Zadanie | Funkcja/Metoda | Przykład |
|---|---|---|
| Podstawowa lista | get_users() | get_users(['role' => 'author']) |
| Zaawansowane filtry | WP_User_Query | new WP_User_Query(['meta_query' => [...]]) |
| Paginacja | paginate_links() | Z WP_User_Query->get_total() |
| Wyszukiwanie | Parametr search | ['search' => '*tekst*', 'search_columns' => ['display_name']] |
| Cache | wbudowany od 6.3, set_transient() | Wersja w kluczu transientu |
| REST API | register_rest_route() | Endpoint /wp-json/wppoland/v1/users |
| Shortcode | add_shortcode() | [lista_uzytkownikow role="editor"] |
| Widget | WP_Widget | Rejestracja własnego widżetu |
Kluczowe zasady pracy z WP_User_Query
- Używaj
fieldsdla oszczędności pamięci i trzymaj się najwyżej trzech pól, żeby nie wyłączyć wbudowanego cache. - Stronicuj przez
number+pagedi licz strony zget_total(); bez paginacji ustawcount_total => false. - Wczytuj meta z wyprzedzeniem przez
update_meta_cache()przed pętlą. - Cache’uj wyniki dużych katalogów i unieważniaj je przy zapisie profilu.
- Chroń prywatność: nie ujawniaj loginów, nicename ani e-maili publicznie, także w wyszukiwarce i w
/wp/v2/users. - Sanityzuj wszystkie dane wyjściowe (
esc_html,esc_url) i wejście przez listy dozwolonych wartości. - Mierz SQL (
$query->request) na stagingu z realistyczną liczbą użytkowników i meta.
Listy użytkowników publikowane na froncie wymagają ostrożności, bo łatwo w nich o ujawnienie loginów. Jeśli chcesz taki katalog zrobić bezpiecznie, wdrożymy go razem z resztą strony.







