Lập trình · 22/09/2026

Laravel 13 Course – Lesson 28: Laravel 13 Mail, Notifications and Private File Uploads

Lesson 28 — Mail, Notifications and Uploads: TaskFlow lets project owners attach PDFs to tasks and receive queued email notifications. Files stay outside the public directory, and both upload and download enforce authorization.

Mail, Notification và Upload file riêng tư trong Laravel 13

Lesson 28 — Mail, Notifications and Uploads: TaskFlow lets project owners attach PDFs to tasks and receive queued email notifications. Files stay outside the public directory, and both upload and download enforce authorization.

1. Separate file persistence from notification delivery

The workflow is authorization, validation, private storage, metadata persistence and notification enqueue. Upload success does not mean inbox delivery. Enqueue failure is reported without undoing a successful upload and encouraging duplicate submissions. Notification delivery is best-effort, without a durable outbox.

The task_attachments migration stores task_id, path, size and timestamps. Task exposes attachments; TaskAttachment permits only path/size assignment while its relationship assigns task_id. Cascading database deletion removes metadata, not physical files. Task deletion is not exposed yet and will need an explicit cleanup design.

2. Private upload and download

app/Http/Controllers/TaskAttachmentController.php

<?php

namespace App\Http\Controllers;

use App\Models\Project;
use App\Models\Task;
use App\Models\TaskAttachment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rules\File;

class TaskAttachmentController extends Controller
{
    public function store(Request $request, Project $project, Task $task)
    {
        Gate::authorize('update', $task);
        $request->validate(['attachment' => ['required', File::types(['pdf'])->max(2048)]]);
        $file = $request->file('attachment');
        $path = $file->store('task-attachments', 'local');
        if ($path === false) {
            throw new \RuntimeException('Attachment storage failed.');
        }
        try {
            $task->attachments()->create(['path' => $path, 'size' => $file->getSize()]);
        } catch (\Throwable $exception) {
            Storage::disk('local')->delete($path);
            throw $exception;
        }

        // Storage already succeeded. Delivery failure must not undo or duplicate the upload.
        try {
            $project->owner->notify(new \App\Notifications\AttachmentUploaded($project->id));
        } catch (\Throwable $exception) {
            report($exception);
        }

        return to_route('projects.show', $project);
    }

    public function download(Project $project, Task $task, TaskAttachment $attachment)
    {
        Gate::authorize('update', $task);
        abort_unless(Storage::disk('local')->exists($attachment->path), 404);

        return Storage::disk('local')->download($attachment->path, 'attachment-'.$attachment->id.'.pdf', [
            'Content-Type' => 'application/pdf', 'X-Content-Type-Options' => 'nosniff',
            'Cache-Control' => 'no-store, private',
        ]);
    }
}

POST /projects/{project}/tasks/{task}/attachments and its GET /{attachment} download route use auth and scoped binding. Binding validates parent relationships; Gate verifies ownership. Storage generates the filename on the local disk rooted at storage/app/private, not from a client-selected path.

The form uses multipart/form-data and @csrf. Server validation accepts PDFs up to 2,048 KB; browser accept is only a hint. Align PHP and proxy upload limits. MIME validation is not antivirus and cannot establish that a PDF is harmless.

Downloads use a fixed attachment filename, PDF content type, nosniff and no-store/private. Do not expose these files through a public symlink. The project page eager-loads attachment links; emails contain neither a direct storage URL nor a PDF copy.

If metadata insertion fails, the controller attempts to delete the newly stored file and rethrows. Filesystem and database operations do not share a transaction. Crashes or cleanup failures can leave orphans, requiring reconciliation/quarantine for stronger operational guarantees.

3. Deliver through a mail-channel notification

app/Notifications/AttachmentUploaded.php

<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class AttachmentUploaded extends Notification implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;

    public int $timeout = 20;

    public function __construct(public int $projectId)
    {
        $this->onQueue('notifications')->afterCommit();
    }

    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)->subject('TaskFlow: attachment uploaded')
            ->line('A PDF attachment was added to a task in your project.')
            ->action('Open project', route('projects.show', $this->projectId))
            ->line('Sign in to review it. This email does not include the private file.');
    }

    public function backoff(): array
    {
        return [10, 30];
    }
}

The notification implements ShouldQueue, targets notifications and uses afterCommit. Its generic message links to an authenticated project page. MailMessage suits this short notification; a specialized Mailable can serve richer email templates without duplicating delivery paths unnecessarily.

Three attempts, a 20-second timeout and backoff bound processing, but retries do not provide exactly-once email. A provider may accept a message before a worker loses connectivity. Track delivery and provider idempotency where required. Successful queue processing also does not prove inbox placement.

4. Run locally without external mail

# Development .env
MAIL_MAILER=log
QUEUE_CONNECTION=database

php artisan migrate
php artisan queue:work database --queue=notifications,maintenance --tries=3 --timeout=20

Verify the development database before migrating. Sign in, create a project/task and upload a small PDF. A maintenance-only worker will not consume notifications. The log mailer writes email locally, so logs still require access controls and retention.

Production needs a real transport/sender, provider-specific domain authentication, bounce monitoring and sending limits. This lesson configures no production SMTP account and sends no real email. Never commit mail credentials.

5. Test each boundary

tests/Feature/TaskAttachmentTest.php

<?php

namespace Tests\Feature;

use App\Models\Task;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class TaskAttachmentTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
            'database.connections.sqlite.url' => null]);
        DB::purge('sqlite');
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
        Storage::fake('local');
        \Illuminate\Support\Facades\Notification::fake();
    }

    public function test_owner_can_upload_and_download_but_another_user_cannot(): void
    {
        $task = Task::factory()->create();
        $this->actingAs($task->project->owner)->post(route('attachments.store', [$task->project, $task]), [
            'attachment' => UploadedFile::fake()->createWithContent('report.pdf', "%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF"),
        ])->assertRedirect();
        $attachment = $task->attachments()->sole();
        \Illuminate\Support\Facades\Notification::assertSentTo($task->project->owner,
            \App\Notifications\AttachmentUploaded::class);
        Storage::disk('local')->assertExists($attachment->path);
        $url = route('attachments.download', [$task->project, $task, $attachment]);
        $this->get($url)->assertOk()->assertDownload('attachment-'.$attachment->id.'.pdf');
        $this->actingAs(User::factory()->create())->get($url)->assertForbidden();
    }

    public function test_invalid_file_and_unauthorized_upload_create_no_record(): void
    {
        $task = Task::factory()->create();
        $url = route('attachments.store', [$task->project, $task]);
        $this->actingAs($task->project->owner)->postJson($url, [
            'attachment' => UploadedFile::fake()->create('fake.pdf', 1, 'text/plain'),
        ])->assertUnprocessable();
        $this->actingAs(User::factory()->create())->postJson($url, [])->assertForbidden();
        $this->assertDatabaseCount('task_attachments', 0);
    }
}

tests/Feature/AttachmentNotificationTest.php

<?php

namespace Tests\Feature;

use App\Models\User;
use App\Notifications\AttachmentUploaded;
use Illuminate\Notifications\SendQueuedNotifications;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

class AttachmentNotificationTest extends TestCase
{
    public function test_notification_is_queued_and_mail_contains_only_a_project_link(): void
    {
        Queue::fake();
        $user = new User(['name' => 'Local recipient', 'email' => 'recipient@example.test']);
        $notification = new AttachmentUploaded(123);
        Notification::send($user, $notification);
        Queue::assertPushedOn('notifications', SendQueuedNotifications::class);
        $mail = $notification->toMail($user);
        $this->assertSame(['mail'], $notification->via($user));
        $this->assertSame(route('projects.show', 123), $mail->actionUrl);
        $this->assertSame([], $mail->attachments);
        $this->assertStringContainsString('Open project', (string) $mail->render());
    }
}
php artisan test --filter=TaskAttachmentTest
php artisan test --filter=AttachmentNotificationTest
php artisan test

The suite passed 84 tests with 394 assertions. Fake storage and in-memory SQLite verify owner access, other-user denial, invalid MIME rejection and notification dispatch. Notification tests check queue selection, rendered links and absence of attachments, without external transport.

Fake files validate behavior against supplied MIME metadata rather than proving real-file scanning. Total quotas, antivirus, deletion UI and storage-crash tests remain unimplemented. Exercises: reject oversized PDFs, reject attachments under the wrong task, handle missing stored files and design orphan cleanup without deleting referenced objects.

Navigation: Lesson 27 · Roadmap. Next: realtime with Reverb.

Laravel 13 course navigation

Previous lesson (27) · Next lesson (29) · All 37 lessons

Discussion

Comments 0

Sign in to comment

You need an account to join the discussion and reply to other readers.

Sign inRegister

No comments yet. Be the first to share your thoughts.