To let a user update their profile in Laravel 13, create an authenticated GET and PATCH route, validate the input with a form request that uses Rule::unique('users')->ignore($this->user()) for the email, and save only validated fields on $request->user(). Put password changes in a separate form that requires the current password.
Starter kits include profile pages, but many apps have custom user fields or no starter kit at all. This tutorial builds the feature by hand so every piece is visible.
Prerequisites
- Laravel 13 with PHP 8.3 or newer.
- Working login with the
authmiddleware. - An
avatar_pathnullable string column onusersif you want avatars. - The public disk linked with
php artisan storage:link.
Step 1: Define the routes
use App\Http\Controllers\ProfileController;
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::put('/profile/password', [ProfileController::class, 'updatePassword'])->name('profile.password');
});There is no user id in the URL. The controller always works on the signed-in user, so one user can never edit another by changing a number.
Step 2: Validate profile details with a form request
php artisan make:request UpdateProfileRequest<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProfileRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique('users', 'email')->ignore($this->user()),
],
'avatar' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
];
}
}Without ignore(), saving the form with an unchanged email would fail because that email already exists: it belongs to the same user. Passing the user model tells the rule to skip that row.
Step 3: Update the profile in the controller
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UpdateProfileRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
class ProfileController extends Controller
{
public function edit(Request $request): View
{
return view('profile.edit', ['user' => $request->user()]);
}
public function update(UpdateProfileRequest $request): RedirectResponse
{
$user = $request->user();
$user->fill($request->safe()->only(['name', 'email']));
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
if ($request->hasFile('avatar')) {
$newPath = $request->file('avatar')->store('avatars', 'public');
if ($user->avatar_path) {
Storage::disk('public')->delete($user->avatar_path);
}
$user->avatar_path = $newPath;
}
$user->save();
return redirect()->route('profile.edit')->with('status', 'Profile updated.');
}
public function updatePassword(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('password', [
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return redirect()->route('profile.edit')->with('status', 'Password changed.');
}
}Three details are worth pointing out. $request->safe()->only() takes values from validated data only, so an extra field like is_admin in the request is ignored. When the email changes, email_verified_at is cleared so the user must verify the new address. The old avatar is deleted only after the new one is stored.
Why is the password form separate?
The current_password rule checks the entered value against the logged-in user's hash. Asking for it protects accounts left signed in on a shared computer. Keeping it in its own form with validateWithBag('password', ...) means its errors do not mix with profile errors on the same page.
Step 4: Build the Blade form
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
@csrf
@method('PATCH')
<label>Name</label>
<input name="name" value="{{ old('name', $user->name) }}" required>
@error('name') <p class="error">{{ $message }}</p> @enderror
<label>Email</label>
<input type="email" name="email" value="{{ old('email', $user->email) }}" required>
@error('email') <p class="error">{{ $message }}</p> @enderror
<label>Avatar</label>
<input type="file" name="avatar" accept="image/*">
@error('avatar') <p class="error">{{ $message }}</p> @enderror
<button type="submit">Save</button>
</form>
<form method="POST" action="{{ route('profile.password') }}">
@csrf
@method('PUT')
<input type="password" name="current_password" autocomplete="current-password">
@error('current_password', 'password') <p class="error">{{ $message }}</p> @enderror
<input type="password" name="password" autocomplete="new-password">
<input type="password" name="password_confirmation" autocomplete="new-password">
@error('password', 'password') <p class="error">{{ $message }}</p> @enderror
<button type="submit">Change password</button>
</form>The enctype attribute is required for file uploads. old() keeps the user's input after a validation error.
Common mistakes
- Using
$request->all()withupdate(). It invites mass assignment problems. Use validated data. - Forgetting
ignore()on the unique rule. Users see "The email has already been taken" for their own email. - Storing a plain password. The default Laravel 13
Usermodel has a'password' => 'hashed'cast, which hashes plain values for you and leaves already hashed values alone. If your model has no such cast, you must callHash::make()yourself. - Missing
enctype. The avatar arrives empty and validation passes because the field is nullable.
How do you verify it works?
<?php
use App\Models\User;
test('a user can update their name and email', function () {
$user = User::factory()->create();
$this->actingAs($user)
->patch('/profile', ['name' => 'New Name', 'email' => 'new@example.com'])
->assertRedirect('/profile')
->assertSessionHasNoErrors();
expect($user->fresh())
->name->toBe('New Name')
->email->toBe('new@example.com')
->email_verified_at->toBeNull();
});
test('the current password is required to change it', function () {
$user = User::factory()->create();
$this->actingAs($user)
->put('/profile/password', [
'current_password' => 'wrong-password',
'password' => 'N3w-Strong-Passw0rd!',
'password_confirmation' => 'N3w-Strong-Passw0rd!',
])
->assertSessionHasErrorsIn('password', 'current_password');
});Where should avatar files be stored?
The example uses the public disk because avatars are shown to other users and do not need protection. Display them with Storage::url($user->avatar_path) and fall back to a default image when the column is null. If you later move files to S3, only the disk name changes. For very large uploads, resize the image to a fixed size before storing it, so every avatar loads quickly in lists and comments.
Profile pages are also a good place to log changes. An updated observer on User that records when the email changed helps support staff answer "I never changed my email" tickets.
FAQ
How do I let users delete their account?
Add a DELETE route that validates current_password, logs the user out, deletes the record, and invalidates the session.
Should profile updates log out other devices?
For password changes, yes. Call Auth::logoutOtherDevices($password) and make sure the auth.session middleware is applied to the routes you want to protect.
Can I use this with an API?
Yes. The same form request works with Sanctum; return JSON instead of redirects.



