Profile Settings
The account page where a signed-in user edits their name and email address, resends a verification link, and deletes their account — rendered inside whichever panel they are in. The page draws the form; the application still owns the write. You reach for this page to know what it sends, what it posts to, and what your application has to provide for the form to work.
A minimal working example
Nothing to register. A panel that says nothing about settings has the page:
<?php
declare(strict_types=1);
namespace App\Panels\Admin;
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelProvider;
final class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel->path('admin')->auth();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
php artisan route:list --name=panel.admin.pages.settings-profileGET admin/settings/profile panel.admin.pages.settings-profileThe page class
PandaPanel\Pages\Settings\ProfileSettings extends PandaPanel\Pages\Page.
| Member | Value |
|---|---|
$title | 'Profile' |
$subheading | 'Update your name and email address.' |
$slug | 'settings-profile' |
$component | 'panel/settings/Profile' |
$navigationIcon | 'user' |
$navigationGroup | 'Account' |
$navigationSort | 10 |
$middleware | none |
routePath() | 'settings/profile' |
A nested path while the slug stays one segment: the slug is a route name and a registry key, the path is what the address bar shows.
use PandaPanel\Pages\Settings\ProfileSettings;
ProfileSettings::routeName('admin'); // 'panel.admin.pages.settings-profile'
ProfileSettings::url('admin'); // '/admin/settings/profile'
ProfileSettings::url('app'); // '/app/settings/profile'2
3
4
5
public static function routeName(PandaPanel\Core\Panel|string|null $panel = null): string
public static function url(PandaPanel\Core\Panel|string|null $panel = null): string2
Every panel gets its own copy. Three panels means three routes to the profile page, each rendering inside its own shell, theme, and navigation.
The props
Two, and both are small:
public function props(): array
{
return [
'mustVerifyEmail' => Auth::user() instanceof MustVerifyEmail,
'status' => session('status'),
];
}2
3
4
5
6
7
| Prop | Type | Meaning |
|---|---|---|
mustVerifyEmail | bool | Whether the user model implements Illuminate\Contracts\Auth\MustVerifyEmail |
status | string|null | The status flash key. 'verification-link-sent' after a resend |
The user themself is not a prop here. The Vue page reads usePage().props.auth.user, which is the application's own shared prop:
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => ['user' => $request->user()],
];
}2
3
4
5
6
7
8
The package's SharePanelData shares panel, navigation, panels, broadcasting, search, notifications and tenancy, and deliberately not auth — who is signed in is a thing an application shares whether or not it has a panel.
What the screen renders
resources/js/pages/panel/settings/Profile.vue, inside PanelLayout:
<script setup lang="ts">
import { Form, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/DeleteUser.vue';
import { send } from '@/routes/verification';
const user = computed(() => usePage().props.auth.user);
</script>
<template>
<Form v-slot="{ errors, processing }" v-bind="ProfileController.update.form()">
<!-- name, email -->
</Form>
<DeleteUser />
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Three things post out of this page, and none of them is a panel route:
| Control | Target | Generated by |
|---|---|---|
| Save | ProfileController.update — PATCH /settings/profile | Wayfinder, from your route table |
| Re-send the verification email | @/routes/verification send — POST /email/verification-notification | Wayfinder, from Fortify's routes |
| Delete account | ProfileController.destroy — DELETE /settings/profile | Wayfinder |
The unverified notice is drawn only when mustVerifyEmail is true anduser.email_verified_at is null, and the confirmation line under it appears when status === 'verification-link-sent'.
The write stays in the application
The panel owns the screen; the application owns the write. That is what keeps exactly one place updating a profile, whether the form was submitted from the panel or from a starter kit page that was never migrated.
examples/app/Http/Controllers/Settings/ProfileController.php is the shape:
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
final class ProfileController
{
public function update(Request $request): RedirectResponse
{
$user = $request->user();
abort_if(! $user instanceof User, 403);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => [
'required', 'string', 'email', 'max:255',
Rule::unique(User::class)->ignore($user->id),
],
]);
$user->fill($validated);
// Changing an address un-verifies it. The old one is no longer proof
// of anything, and the new one has not been proven yet.
if ($user->isDirty('email') && $user instanceof MustVerifyEmail) {
$user->email_verified_at = null;
}
$user->save();
return back()->with('success', 'Profile updated.');
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Two details are load-bearing. Only validated attributes are assigned, so a field the form never offered — is_admin, say — cannot arrive by being added to the request. And back()->with('success', …) lands on the panel's toast channel, because ShareFlashToast maps Laravel's conventional flash keys onto the one channel the frontend listens on.
Keeping the application's /settings/profile URL
Once the panel owns the screen, keep the old address as an alias rather than as a second implementation, so bookmarks and generated links still resolve:
use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\Settings\SettingsRedirectController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function (): void {
Route::get('settings', [SettingsRedirectController::class, 'profile']);
Route::get('settings/profile', [SettingsRedirectController::class, 'profile'])->name('profile.edit');
// The screen moved into the panel; the write did not.
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
});2
3
4
5
6
7
8
9
10
11
private function toPanel(Request $request, string $page): RedirectResponse
{
$panel = app(PandaPanel\Core\PanelManager::class)->firstAccessibleTo($request->user());
abort_if($panel === null || ! $panel->hasSettings(), 403);
return redirect($page::url($panel));
}2
3
4
5
6
7
8
firstAccessibleTo() is the same predicate the panel routes enforce, so a user lands in a panel they can actually enter. hasSettings() is checked because ProfileSettings::url() has no route to build from on a panel that turned settings off. The full example is examples/app/Http/Controllers/Settings/SettingsRedirectController.php.
Turning the page off
public function settings(bool $settings = true): self
public function hasSettings(): bool2
Panel::make('kiosk')->settings(false)->getPages(); // []All-or-nothing: profile, security and appearance go together.
Testing it
use Inertia\Testing\AssertableInertia;
it('renders the profile settings page in the panel shell', function (): void {
$this->actingAs($admin)
->get('/admin/settings/profile')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('panel/settings/Profile')
// The panel prop is what puts it in the panel's own shell rather
// than the starter kit's.
->where('panel.id', 'admin')
->where('page.title', 'Profile')
->has('mustVerifyEmail')
);
});
it('refuses a panel it may not enter, settings included', function (): void {
$this->actingAs($member)->get('/admin/settings/profile')->assertForbidden();
});
it('sends a guest to login rather than rendering settings', function (): void {
$this->get('/app/settings/profile')->assertRedirect(route('login'));
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
That is tests/Feature/Panel/PanelSettingsTest.php, near enough verbatim.
Gotchas
- The route is GET only. A POST to
/admin/settings/profileanswers 405. Nothing about this page accepts a write. - The page needs three host modules.
@/actions/App/Http/Controllers/Settings/ProfileController,@/routes/verificationand@/components/Heading(throughDeleteUser). All three are yours: Wayfinder generates the first two from your own routes, andphp artisan panel:installnames any that are missing. auth.usermust be shared. The page reads it from Inertia's shared props. An application that does not shareauth.userrenders a page that throws onuser.name— the package cannot share it for you, because that prop belongs to the application's own middleware.- Deleting an account is the application's endpoint too.
ProfileController.destroyisDELETE /settings/profile. If your application has no such route,DeleteUser.vueposts into nothing; remove the component from the published page or add the route. - Panel access is checked before the page is. A user the panel refuses gets 403 here exactly as on any other page of it, and a guest is redirected to the login — the panel's own when it has one.
- The email field un-verifies on change only if you write it that way. The
mustVerifyEmailprop draws the notice; nothing in the package clearsemail_verified_at. That line lives in your controller.