K

Modules

Modul uživatelů

Hotová správa uživatelů instalovaná jako balíček — resource uživatelů, jeho stránky a správa rolí všude, kde aplikace role má.

Na této stránce

Modul, který přichází jako composer balíček: nainstalujete ho a aplikace má správu uživatelů, aniž byste kamkoli vypsali třídu nebo sáhli do configu.

composer require nyoncode/wire-module-users
php artisan wire-module-users:install

Zároveň je to referenční implementace té cesty — samoregistrace přes plugin manager, manifest, který jmenuje své resources, a vlastní skupina v menu.

Jak to funguje

Nad vaším modelem uživatele, nikdy nad vlastním. Balíček, který by si přinesl vlastní tabulku uživatelů, by byl nepoužitelný v každé aplikaci, která ji už má — tedy ve všech. config('wire-module-users.model') míří na App\Models\User a tři sloupce, kterých se modul dotýká (name, email, password), jsou taky konfigurace, protože users je ta jediná tabulka, kterou každá aplikace změnila.

Heslo je část, kterou je potřeba ošetřit, ne jen zobrazit. Hash se ze stavu formuláře odstraňuje, místo aby se spoléhalo na to, že je skrytý — model aplikace bez $hidden by ho jinak poslal do Livewire snapshotu a při dalším uložení znovu zahashoval. Prázdné pole znamená nechat současné heslo; vyplněné se cestou do databáze zahashuje.

Role se objeví jen tam, kde jsou. S nainstalovaným nyoncode/laravel-permission-extended a s modelem uživatele, který nese jeho trait HasRoles, modul zaregistruje resource rolí a přidá pole rolí do formuláře uživatele. Kontrolují se obě podmínky: balíček může být nainstalovaný, zatímco model trait nikdy nevzal — a každé uložení rolí by pak spadlo na posledním kroku.

Tenhle balíček je permission vrstva celého stacku — ne alternativa ke spatie/laravel-permission, který vyžaduje a rozšiřuje, ale wildcard matching, super-admin gate a události o změně oprávnění, které tyhle obrazovky předpokládají. Model na holém Spatie se záměrně nedetekuje; 'roles' => true ten pohled přebije.

Oprávnění jsou zaškrtávací seznam, ne multi-select. Ty dvě věci odpovídají na různé otázky: multi-select ukazuje, co jste vybrali, a zbytek schová — což je správně pro dvě tři role u uživatele. U oprávnění je to naopak: otázka zní vždycky „co tam ještě je“, nad stovkou názvů, které si nikdo nepamatuje. Takže na stránce je každé oprávnění, s vyhledávacím polem, dvojicí Vybrat vše / Zrušit výběr a skupinami podle resource, o kterém oprávnění je — podle úseku před první tečkou (invoices.view, invoices.*invoices).

Seskupí se jen tam, kde to něco oddělí. Jedna skupina je tentýž seznam s nadpisem nad ním a aplikace, jejíž oprávnění nejsou s tečkou — view invoices, ta druhá běžná konvence — by dostala jednu skupinu na každé oprávnění; obojí se proto vykreslí naplocho. Název bez resource se nezahodí: sesbírá se pod jeden nadpis, protože oprávnění, které ve formuláři chybí, je oprávnění, které nikdo nemůže udělit.

Role se zapisují až po vzniku záznamu. Žijí v pivot tabulce, takže se z dat cestou k save() odeberou a synchronizují se potom — jediné pořadí, které funguje pro nového uživatele, který do té chvíle nemá id.

Autorizace nikdy nepatří tomuhle modulu. Každá kontrola v tomhle stacku je Gate::allows(), do kterého se oba permission balíčky registrují samy, takže wildcard (invoices.*) i super-admin bypass fungují, aniž by o nich modul věděl. Modul spravuje data; kdo je smí spravovat, je vaše policy.

Všechno volitelné se detekuje, nepředpokládá. Role, avatary, dvoufázové ověření i týmy mají nastavení auto, které hledá tu věc samotnou, ne příznak, který jste si vzpomněli nastavit — třídu role a trait na vašem modelu, sloupec v tabulce uživatelů, Fortify se zapnutou funkcí, permission.teams. Kde ta věc chybí, chybí i plocha — místo aby tam byla a nefungovala. php artisan about jmenuje všechny čtyři a instalátor taky, protože nahrávání fotky, které se nikdy neobjeví kvůli chybějícímu sloupci, je k nerozeznání od rozbitého balíčku, dokud to někdo neřekne.

Avatar je sloupec, který vlastníte vy. wire-module-users.avatar.column (ve výchozím stavu avatar_path) drží cestu na disku wire-module-users.avatar.disk a shell ji kreslí přes kontrakt HasAvatar z wire-core — nikdy ne přes tenhle balíček. Horní lišta tak ukáže tvář, aniž by věděla, odkud je, a aplikace, která řeší Gravatar nebo identity providera, implementuje stejné rozhraní, místo aby brala trait tohohle modulu. Uložená hodnota, která je URL, projde beze změny — a právě to tohle umožňuje.

Heslo se z profilu odstěhovalo. Má vlastní kartu, která se nejdřív zeptá na současné heslo. Pole s poznámkou „nechte prázdné pro zachování hesla“ je ovládací prvek administrátora — a na stránce profilu je administrátorem sám držitel účtu, takže obě obrazovky ho nemůžou sdílet. Admin, který edituje někoho jiného, žádné současné heslo nemá. V tom je celý rozdíl.

Konfigurace

// config/wire-module-users.php
'model' => 'App\Models\User',
 
'fields' => [
'name' => 'name',
'email' => 'email',
'password' => 'password',
],
 
'roles' => 'auto', // 'auto' se podívá, true a false odpoví za vás
 
'avatar' => [
'enabled' => 'auto', // 'auto' hledá sloupec níž
'column' => 'avatar_path',
'disk' => 'public',
'directory' => 'avatars',
],
 
'profile' => [
'password' => true,
'two_factor' => true,
'passkeys' => true,
'delete_account' => false, // ve výchozím stavu vypnuté — viz níž
'menu_item' => true, // odkaz na profil v uživatelském menu shellu
],
 
'two_factor' => 'auto', // 'auto' hledá Fortify se zapnutou funkcí
'passkeys' => 'auto', // 'auto' hledá laravel/passkeys se zapnutou funkcí
 
'teams' => [
'enabled' => 'auto', // 'auto' se řídí permission.teams
'model' => 'App\\Models\\Team',
'relation' => 'teams',
'label_attribute' => 'name',
'session_key' => 'wire.team',
],
 
'navigation' => [
'group' => 'access',
'label' => null, // null použije vlastní nadpis skupiny modulu
'icon' => 'outline:users',
'sort' => 90,
],

Všechno v zvýrazněném bloku je volitelné a každé auto výš umí čistě odpovědět „ne“. Aplikace, která nenastaví nic z toho, dostane správu uživatelů, jakou měla.

Co dostanete

Obrazovka Poznámky
Seznam uživatelů Jméno a e-mail k hledání i řazení, fotka tam, kde je, sloupec rolí tam, kde role jsou. Detail, Upravit a Smazat na řádku; Nový uživatel v toolbaru
Nový uživatel Heslo jednou povinně, role se přiřadí při uložení
Editace uživatele Heslo volitelně — prázdné nechá současné; role načtené ze záznamu
Detail uživatele Jen ke čtení, v hlavičce jméno člověka: fotka, jméno, e-mail a role, které má
Seznam, nová a editace rolí Jen tam, kde role jsou. Detail, Upravit a Smazat na řádku; oprávnění jsou zaškrtávací seznam s vyhledáváním, seskupený podle resource, a editují se jako názvy, takže wildcard je prostě název
Detail role Jen ke čtení, v hlavičce název role. Nahoře co role je, pod tím co uděluje — a tam, kde permission vrstva matchuje wildcardy, je invoices.* vypsaný zvlášť od oprávnění, která pokrývá

Každé tlačítko se řídí tím, co aplikace naroutovala. Řádková akce je skrytá tam, kde její stránka naroutovaná není — místo odkazu, který skončí na 404. Aplikace může namountovat jen seznam a nic dalšího, a poctivá odpověď na „žádná editační stránka není“ je žádné Upravit. Tam, kde naroutovaná je i s oprávněním (RoutePage::make(EditUser::class)->permission('users.update')), si tlačítko přečte ability z téže deklarace — skryté tlačítko a hlídaná routa se tak nemůžou rozejít.

Sám sebe ze seznamu uživatelů smazat nemůžete. Není to zdvořilost: administrátor, který odstraní vlastní řádek, se uprostřed requestu odhlásí do aplikace, kam už se nedostane — a pokud byl jediný, nedostane se tam nikdo. Zavření vlastního účtu je věc stránky profilu, kde se ptá dvakrát a chce heslo.

Kdo Se K Těmto Obrazovkám Dostane

Tyto obrazovky vyžadují oprávnění, a to je ve 2.0 nové. Formulář pro úpravu uživatele nastavuje ostatním lidem hesla a přiděluje jim role, takže obrazovka otevřená komukoli, koho pustil auth middleware panelu, je obrazovka, která z libovolného účtu udělá správce. Před 2.0 se dodávaly otevřené a selhání bylo tiché — panel vypadal správně, zatímco podával správu uživatelů každému, kdo se dokázal přihlásit.

Nově tedy selhávají zavřeně. Každá obrazovka pojmenuje oprávnění, to se stane Laravelím vlastním can: middleware na route, a tlačítko, které tam vede, se skryje podle téže deklarace:

// config/wire-module-users.php — výchozí hodnoty
'permissions' => [
'users' => [
'viewAny' => 'users.viewAny',
'view' => 'users.view',
'create' => 'users.create',
'update' => 'users.update',
],
 
'roles' => [
'viewAny' => 'roles.viewAny',
'view' => 'roles.view',
'create' => 'roles.create',
'update' => 'roles.update',
],
],

Nic tady autorizační kontrolu neimplementuje znovu — na každou z nich odpovídá Gate, a právě proto funguje zástupný znak (users.*), policy i obejití pro super-admina z nyoncode/laravel-permission-extended, aniž by o nich tento modul věděl. Na tom balíčku super-admin projde vždy.

Na instalaci, kde žádné takové oprávnění definované není, tyto obrazovky odpoví 403. To je záměr: viditelný problém se zřejmou opravou je lepší než tichý. Definujte oprávnění, přidělte je roli, nebo obrazovku znovu otevřete pomocí null:

'permissions' => [
'users' => [
'viewAny' => 'users.viewAny',
'view' => null, // otevřené komukoli, koho panel pustil
'create' => 'users.create',
'update' => 'users.update',
],
],

Prázdný řetězec se počítá jako null, ne jako oprávnění, které nikdo nemůže mít — takový tvar vyrobí nenastavené .env a can: nad ním by odepřelo přístup všem bez možnosti ho udělit.

Profilová stránka na tomto seznamu záměrně není. Je to vlastní účet přihlášeného člověka, získaný z Auth::user() a ne z URL, takže administrátorské oprávnění před ní by každého uživatele zamklo od jeho vlastního hesla a nastavení dvoufázového ověření.

php artisan about hlásí, jak je to na této instalaci nastavené, a installer to řekne při instalaci — instalace, která tyto obrazovky otevřela, by to měla mít možnost vidět.

Role Se Kontrolují I Tam, Kde Se Zapisují

Route guard není jediný zámek. Přidělení role prochází druhou kontrolou přímo u zápisu do pivot tabulky, protože cest k formuláři je mnoho a ne všechny jsou route — hromadná akce, krok průvodce, vlastní stránka aplikace skládající SyncsRoles. Uložení, které role mění, vyžaduje oprávnění users.update; uložení, které je nechává být, ne — takže oprava překlepu ve jméně nikdy nikomu role nesebere.

Změněná adresa přestane být ověřená

Fortifyho UpdateUserProfileInformation při úpravě adresy vynuluje email_verified_at a pošle nové ověření. Tenhle modul tu akci nahrazuje vlastním formulářem, takže to pravidlo zopakuje, místo aby ho ztratil — jinak by si člověk mohl napsat adresu, kterou nevlastní, a zůstat na ní označený jako ověřený. Cokoli za Laravelím middleware verified nebo jakákoli policy ptající se hasVerifiedEmail() by pak platily pro adresu, kterou nikdo nedoložil.

Pravidlo žije na modelu, ne ve form hooku, a to záměrně: ten sloupec tu zapisují tři obrazovky — profil, administrátorská úprava, vytvoření — a Form::afterSave() drží přesně jednu closure, takže pravidlo umístěné tam by příští stránka, která si přidá hook, tiše odstranila. Vlastní stránka aplikace nebo konzolový příkaz by pokryté nebyly nikdy.

Je úzké. Spustí se jen když se adresa opravdu změnila, a ustoupí vždy, když tentýž zápis nastavuje i email_verified_at — seeder, doplnění dat v migraci nebo administrátorský nástroj označující adresu za ověřenou řekly, co chtějí:

// Respektováno: volající měl názor.
$user->forceFill(['email' => $new, 'email_verified_at' => now()])->save();
 
// Vynulováno, a odejde nové ověření.
$user->forceFill(['email' => $new])->save();

Vypněte tam, kde si aplikace ten příznak řeší po svém:

// config/wire-module-users.php
'reverify_on_email_change' => false,

Úpravy obrazovek

fields mapuje tři jména sloupců, nic víc. Je tu pro tabulku uživatelů, jejíž sloupce se jmenovaly dřív, než tenhle modul přišel — odpovídá na otázku „ve kterém sloupci je jméno", nikdy na „z kolika polí se jméno skládá":

'fields' => ['name' => 'full_name', 'email' => 'login', 'password' => 'password'],

Všechno ostatní na seznamu, ve formuláři i na detailu se upravuje přes hooky zúžené na klíč, pod kterým se modul zaregistroval — users. Dědění z UserResource nefunguje: potomek si nechá klíč rodiče a registr odmítne dvě třídy na jednom klíči — a přesně to dělá z modulu něco upravitelného místo něčeho, co se forkuje.

Křestní jméno a příjmení ve dvou sloupcích

Případ, který konfigurace neumí vyjádřit a hooky ano. Čtyři kroky, a jen ten poslední má s tímhle modulem vůbec co do činění.

1. Sloupce. Obyčejná migrace; name si nechte nebo zahoďte, jak chcete — po kroku 2 ho jako sloupec nikdo nečte:

Schema::table('users', function (Blueprint $table): void {
$table->string('first_name')->after('id')->default('');
$table->string('last_name')->after('first_name')->default('');
});
 
// Naplňte data dřív, než něco zahodíte: půlky jména se ze sloupce, který už
// není, zpátky nedostanou.
DB::table('users')->orderBy('id')->each(function (object $user): void {
[$first, $last] = array_pad(explode(' ', (string) $user->name, 2), 2, '');
 
DB::table('users')->where('id', $user->id)->update([
'first_name' => $first,
'last_name' => $last,
]);
});

2. Model. Oba sloupce fillable a accessor, aby všechno, co jméno jen zobrazuje, fungovalo dál — roh shellu, iniciály v avataru i sloupec aktéra v audit logu čtou $user->name a je jim jedno, odkud se vzalo:

// app/Models/User.php
use Illuminate\Database\Eloquent\Casts\Attribute;
 
protected $fillable = ['first_name', 'last_name', 'email', 'password'];
 
protected function name(): Attribute
{
return Attribute::get(fn (): string => trim($this->first_name.' '.$this->last_name));
}

3. Řádek konfigurace. Nasměrujte fields.name na sloupec, podle kterého má seznam řadit — na skutečný sloupec, protože sortable() a výchozí řazení se promění v SQL a accessor sloupec není:

// config/wire-module-users.php
'fields' => ['name' => 'last_name', 'email' => 'email', 'password' => 'password'],

4. Obrazovky, a tady přicházejí na řadu hooky:

namespace App\Wire\Plugins;
 
use Illuminate\Database\Eloquent\Model;
use NyonCode\WireCore\Core\Plugin\Contracts\Plugin;
use NyonCode\WireCore\Core\Plugin\Hooks\FormConfiguringPayload;
use NyonCode\WireCore\Core\Plugin\Hooks\InfolistConfiguringPayload;
use NyonCode\WireCore\Core\Plugin\Hooks\TableComposingPayload;
use NyonCode\WireCore\Core\Plugin\PluginManager;
use NyonCode\WireCore\Foundation\Components\LayoutComponent;
use NyonCode\WireCore\Foundation\Enums\Hook;
use NyonCode\WireCore\Infolists\Components\TextEntry;
use NyonCode\WireForms\Components\Field;
use NyonCode\WireForms\Components\TextInput;
use NyonCode\WireModuleUsers\Resources\UserResource;
use NyonCode\WireTable\Columns\TextColumn;
 
final class SplitUserName implements Plugin
{
public function getId(): string
{
return 'split-user-name';
}
 
public function register(PluginManager $manager): void
{
$manager->hook(Hook::FormConfiguring, function (FormConfiguringPayload $payload): FormConfiguringPayload {
$payload->schema = $this->splitName($payload->schema);
 
return $payload;
}, for: 'users');
 
$manager->hook(Hook::TableComposing, function (TableComposingPayload $payload): TableComposingPayload {
$payload->columns = array_map(
fn (object $column): object => $column->getName() === UserResource::field('name')
// Jeden sloupec a obě půlky v něm: hledání čte oba sloupce,
// řazení zůstane na tom skutečném pod ním.
? TextColumn::make(UserResource::field('name'))
->label(__('Jméno'))
->state(fn (Model $record): string => trim($record->first_name.' '.$record->last_name))
->searchable(['first_name', 'last_name'])
->sortable()
: $column,
$payload->columns,
);
 
return $payload;
}, for: 'users');
}
 
public function boot(PluginManager $manager): void {}
 
/**
* Schéma modulu s jedním inputem na jméno nahrazeným dvěma.
*
* Rekurzivně, protože resource skládá pole do sekcí — ze stejného důvodu,
* z jakého je rekurzivní i filtr profilové stránky.
*
* @param array<int, mixed> $schema
* @return array<int, mixed>
*/
private function splitName(array $schema): array
{
$name = UserResource::field('name');
$out = [];
 
foreach ($schema as $component) {
if ($component instanceof LayoutComponent) {
$out[] = $component->schema($this->splitName($component->getSchema()));
 
continue;
}
 
if ($component instanceof Field && $component->getName() === $name) {
$out[] = TextInput::make('first_name')->label(__('Křestní jméno'))->required();
$out[] = TextInput::make('last_name')->label(__('Příjmení'))->required();
 
continue;
}
 
$out[] = $component;
}
 
return $out;
}
}

Zaregistrovaný tak, jak se registruje každý plugin aplikace:

// config/wire-core.php
'plugins' => [
App\Wire\Plugins\SplitUserName::class,
],

Detail je tytéž tři řádky se třetím hookem a tady odvede práci accessor z kroku 2 — infolist jen zobrazuje, takže se nic nehledá ani neřadí:

$manager->hook(Hook::InfolistConfiguring, function (InfolistConfiguringPayload $payload): InfolistConfiguringPayload {
$payload->schema = array_map(
fn (object $entry): object => $entry->getName() === UserResource::field('name')
? TextEntry::make('name')->label(__('Jméno')) // accessor, ne sloupec
: $entry,
$payload->schema,
);
 
return $payload;
}, for: 'users');

Tři věci stojí za to vědět, než to spustíte:

  • Hook::FormConfiguring dosáhne i na profilovou stránku. Vlastní účet skládá tentýž formulář resourcu a jen z něj dvě pole vyndá, takže obě obrazovky dostanou dvojici z jednoho callbacku. Hook::ExportConfiguring udělá totéž pro obsah stažení.
  • Samotný accessor by nestačil. Formulář i detail naplní naprosto v pořádku a pak se searchable() a defaultSort() seznamu zeptají databáze na sloupec, který neexistuje — proto krok 3 nasměruje modul na skutečný.
  • Fortify ani balíčky na oprávnění jméno nevidí, takže zbytek stacku nemá na počet sloupců názor.

Texty na těchhle obrazovkách jsou publikovatelný překladový soubor a jejich markup publikovatelný pohled — wire-module-users::translations a …::views; co to stojí, říká Vzhled → Lokalizace a Přepis pohledů.

Vlastní účet

EditProfile je stránka přihlášeného uživatele o něm samotném — a není to jeden formulář. Každá karta pod tou první je samostatná Livewire komponenta: jiná otázka, jiné tlačítko, jiná věc, která může selhat.

Není to preference rozvržení. Jeden formulář se třemi sekcemi musí umět vysvětlit, co se stalo, když neprojde ta prostřední — a „jméno se uložilo, heslo ne“ není zpráva, kterou by stránka profilu měla kdy vyprodukovat.

Karta Komponenta Zobrazí se, když
Údaje o profilu samotný EditProfile vždycky — formulář resource bez rolí a hesla
Změna hesla UpdatePassword profile.password
Dvoufázové ověření TwoFactorAuthentication profile.two_factor a je nainstalovaný Fortify
Passkeys PasskeyManagement profile.passkeys a Fortify routuje Features::passkeys()
Smazání účtu DeleteAccount profile.delete_accountve výchozím stavu vypnuté

Záznamem je přihlášený uživatel, nikdy parametr routy. Stránka profilu, která by brala id, by byla editace s přívětivějším jménem — a při prvním přepsání čísla v URL převzetí cizího účtu.

Role jsou ze schématu odstraněné, ne jen ignorované při uložení. Kdo edituje vlastní účet, se nesmí sám přidat do role, a pole, které se vykreslí a pak zahodí, je jeden refaktor od pole, které se vykreslí a pak respektuje.

Změna hesla vás nechá přihlášené. Laravelí middleware AuthenticateSession porovnává kopii hashe hesla v session s tou uživatelovou při každém requestu, takže změna, která tu kopii neposune, vás odhlásí hned při dalším kliknutí — a to právě v aplikacích, které ten middleware zapínají, tedy v těch, kterým na tom záleží nejvíc. Karta ji posune.

Smazání vlastního účtu je ve výchozím stavu vypnuté, a je to rozhodnutí, ne opatrnost: v administraci je člověk na téhle stránce většinou zaměstnanec a administrátor, který se dvěma kliknutími odstraní, je budoucí ticket na podporu. Tam, kde jsou účty samoobslužné, to zapněte. Ptá se dvakrát — dialog, který je potřeba otevřít, a heslo účtu napsané do něj — a co „smazat“ znamená, zůstává vašemu modelu, takže tabulka users se soft delete soft-deletuje.

Routuje se se vším ostatním — Route::wireResources() mu dá {prefix}/users/profile pod jménem wire.users.profile — a v pages() je deklarovaná před view schválně: neznámý klíč stránky routuje na {prefix}/{name}, takže users/profile a users/{record} mají stejný tvar URL a deklarovaná až za ním by se „profile“ hledalo jako klíč uživatele a skončilo 404.

Karta na vlastní stránce

Každá z nich je obyčejná Livewire komponenta bez argumentů, protože záznam, na kterém pracuje, je vždycky ten přihlášený. Aplikace, která chce kartu hesla na vlastní stránce nastavení, ji tam dá a tady ji vypne:

// config/wire-module-users.php
'profile' => [
'password' => false,
],
{{-- resources/views/settings/security.blade.php --}}
<x-wire-admin::layout title="Zabezpečení">
<div class="mx-auto max-w-3xl space-y-4">
@livewire(\NyonCode\WireModuleUsers\Livewire\UpdatePassword::class)
@livewire(\NyonCode\WireModuleUsers\Livewire\TwoFactorAuthentication::class)
</div>
</x-wire-admin::layout>

Fotka pro vaše uživatele

Přidejte do vlastní tabulky uživatelů nullable string sloupec a nahrávání se objeví — v tom je celá detekce auto:

// database/migrations/…_add_avatar_to_users_table.php
Schema::table('users', function (Blueprint $table): void {
$table->string('avatar_path')->nullable();
});

Pak modelu řekněte, že má tvář. Kontrakt patří wire-core a jeho čtení tomuhle modulu — a právě tenhle rozdělený vztah nechá shell kreslit avatar, aniž by věděl, že tenhle balíček existuje:

use Illuminate\Foundation\Auth\User as Authenticatable;
use NyonCode\WireCore\Foundation\Contracts\HasAvatar;
use NyonCode\WireModuleUsers\Concerns\InteractsWithAvatar;
 
class User extends Authenticatable implements HasAvatar
{
use InteractsWithAvatar;
 
protected $fillable = ['name', 'email', 'password', 'avatar_path'];
}

To stačí na tři plochy najednou: kulaté nahrávání na profilu, tvář v seznamu uživatelů a fotku v horní liště místo iniciály.

Vlastní odpověď místo toho. Implementujte getAvatarUrl() sami a trait vynechte — Gravatar, identity provider, druhá tabulka. Chrome se ptá rozhraní:

class User extends Authenticatable implements HasAvatar
{
public function getAvatarUrl(): ?string
{
return 'https://www.gravatar.com/avatar/'.md5(strtolower($this->email));
}
}

Vrátit null je platná odpověď, ne selhání: tak vypadá člověk, který nic nenahrál, a každá plocha spadne zpátky na jeho iniciálu.

Související

  • Moduly — co modul je a jak ho balíček dodává
  • Admin shell — rám, ve kterém se tyhle stránky vykreslují
  • Resources — kontrakty, které resources modulu implementují