Skip to content

Proyecto final: portal de clientes ​

Es hora de juntar todo. Construirás "Portal Agencia": un sistema que una agencia web (¡como la tuya!) usa para gestionar los proyectos de sus clientes. Es exactamente el tipo de sistema que te pueden encargar, y uno que puedes usar para tu propio negocio.

Qué vas a construir ​

Roles:

  • Admin (la agencia): crea clientes y proyectos, gestiona tareas, sube entregables.
  • Cliente: inicia sesión, ve sólo sus proyectos, el avance de tareas, descarga entregables y sube archivos (logos, textos).

Funcionalidades y la lección donde las aprendiste:

FuncionalidadLección
Login, recuperación de contraseñaBreeze
Proyectos, tareas y archivos relacionadosRelaciones
Validación con Form RequestsValidación
Roles, middleware y policiesAutorización
Archivos privados con descarga protegidaStorage
Notificación por correo encoladaColas
API de sólo lectura con SanctumAPI
Tests de permisosTesting

Paso 1: crear el proyecto ​

bash
laravel new portal-agencia   # elige: sin starter kit, Pest, MySQL
cd portal-agencia

composer require laravel/breeze --dev
php artisan breeze:install blade
php artisan install:api

php artisan make:model Project -mf
php artisan make:model Task -mf
php artisan make:model Attachment -m
php artisan make:migration add_role_to_users_table
php artisan make:notifications-table

Configura .env (BD, APP_LOCALE=es, MAIL_MAILER=log, QUEUE_CONNECTION=database).

Paso 2: migraciones ​

php
// add_role_to_users_table
public function up(): void
{
    Schema::table('users', function (Blueprint $table) {
        $table->string('role')->default('client')->after('email'); // admin | client
        $table->string('company')->nullable()->after('name');
    });
}
php
// create_projects_table
public function up(): void
{
    Schema::create('projects', function (Blueprint $table) {
        $table->id();
        // El cliente dueño del proyecto
        $table->foreignId('client_id')->constrained('users')->cascadeOnDelete();
        $table->string('name');
        $table->text('description')->nullable();
        $table->string('status')->default('active'); // active | paused | completed
        $table->date('due_date')->nullable();
        $table->timestamps();
    });
}
php
// create_tasks_table
public function up(): void
{
    Schema::create('tasks', function (Blueprint $table) {
        $table->id();
        $table->foreignId('project_id')->constrained()->cascadeOnDelete();
        $table->string('title');
        $table->boolean('done')->default(false);
        $table->unsignedInteger('position')->default(0);
        $table->timestamps();
    });
}
php
// create_attachments_table
public function up(): void
{
    Schema::create('attachments', function (Blueprint $table) {
        $table->id();
        $table->foreignId('project_id')->constrained()->cascadeOnDelete();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete(); // quién lo subió
        $table->string('original_name');
        $table->string('path');          // ruta en el disco "local" (privado)
        $table->unsignedBigInteger('size');
        $table->timestamps();
    });
}
bash
php artisan migrate

Paso 3: modelos ​

php
<?php

// app/Models/User.php (fragmento)
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;

    // "role" NO va en fillable: se asigna explícitamente
    protected $fillable = ['name', 'company', 'email', 'password'];

    protected $hidden = ['password', 'remember_token'];

    protected function casts(): array
    {
        return ['email_verified_at' => 'datetime', 'password' => 'hashed'];
    }

    public function isAdmin(): bool
    {
        return $this->role === 'admin';
    }

    public function projects()
    {
        return $this->hasMany(Project::class, 'client_id');
    }
}
php
<?php

// app/Models/Project.php
namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Project extends Model
{
    use HasFactory;

    public const STATUSES = ['active' => 'En curso', 'paused' => 'En pausa', 'completed' => 'Terminado'];

    protected $fillable = ['client_id', 'name', 'description', 'status', 'due_date'];

    protected function casts(): array
    {
        return ['due_date' => 'date'];
    }

    public function client()      { return $this->belongsTo(User::class, 'client_id'); }
    public function tasks()       { return $this->hasMany(Task::class)->orderBy('position'); }
    public function attachments() { return $this->hasMany(Attachment::class)->latest(); }

    // Porcentaje de avance según tareas terminadas
    public function progress(): int
    {
        $total = $this->tasks_count ?? $this->tasks()->count();
        $done  = $this->done_tasks_count ?? $this->tasks()->where('done', true)->count();

        return $total ? (int) round($done * 100 / $total) : 0;
    }

    // Scope: proyectos visibles para un usuario
    public function scopeVisibleTo(Builder $query, User $user): Builder
    {
        return $user->isAdmin() ? $query : $query->where('client_id', $user->id);
    }
}
php
<?php

// app/Models/Task.php
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Task extends Model
{
    use HasFactory;

    protected $fillable = ['title', 'done', 'position'];

    protected function casts(): array
    {
        return ['done' => 'boolean'];
    }

    public function project() { return $this->belongsTo(Project::class); }
}
php
<?php

// app/Models/Attachment.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;

class Attachment extends Model
{
    protected $fillable = ['user_id', 'original_name', 'path', 'size'];

    public function project() { return $this->belongsTo(Project::class); }
    public function user()    { return $this->belongsTo(User::class); }

    // Al borrar el registro, borrar también el archivo físico
    protected static function booted(): void
    {
        static::deleted(fn (Attachment $a) => Storage::disk('local')->delete($a->path));
    }
}

Paso 4: autorización ​

bash
php artisan make:middleware EnsureUserIsAdmin
php artisan make:policy ProjectPolicy --model=Project
php
<?php

// app/Http/Middleware/EnsureUserIsAdmin.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureUserIsAdmin
{
    public function handle(Request $request, Closure $next): Response
    {
        abort_unless($request->user()?->isAdmin(), 403);

        return $next($request);
    }
}
php
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias(['admin' => \App\Http\Middleware\EnsureUserIsAdmin::class]);
})
php
<?php

// app/Policies/ProjectPolicy.php
namespace App\Policies;

use App\Models\Project;
use App\Models\User;

class ProjectPolicy
{
    // Los admins pueden todo
    public function before(User $user): ?bool
    {
        return $user->isAdmin() ? true : null;
    }

    public function view(User $user, Project $project): bool
    {
        return $project->client_id === $user->id;
    }

    // El cliente puede subir archivos a sus proyectos activos
    public function upload(User $user, Project $project): bool
    {
        return $project->client_id === $user->id && $project->status !== 'completed';
    }

    // Crear, editar y borrar: sólo admin (before ya devolvió true para ellos)
    public function create(User $user): bool { return false; }
    public function update(User $user, Project $project): bool { return false; }
    public function delete(User $user, Project $project): bool { return false; }
}

Paso 5: Form Request ​

bash
php artisan make:request ProjectRequest
php
<?php

namespace App\Http\Requests;

use App\Models\Project;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class ProjectRequest extends FormRequest
{
    public function authorize(): bool
    {
        // Sirve para crear y editar; sólo admins (las rutas ya llevan "admin")
        return $this->user()->isAdmin();
    }

    public function rules(): array
    {
        return [
            'client_id'   => ['required', Rule::exists('users', 'id')->where('role', 'client')],
            'name'        => ['required', 'string', 'max:150'],
            'description' => ['nullable', 'string', 'max:5000'],
            'status'      => ['required', Rule::in(array_keys(Project::STATUSES))],
            'due_date'    => ['nullable', 'date'],
        ];
    }

    public function attributes(): array
    {
        return ['client_id' => 'cliente', 'due_date' => 'fecha de entrega'];
    }
}

Paso 6: controladores ​

bash
php artisan make:controller ProjectController
php artisan make:controller Admin/ProjectController --resource --model=Project
php artisan make:controller TaskController
php artisan make:controller AttachmentController

Panel del usuario (clientes y admin ven la lista y el detalle):

php
<?php

// app/Http/Controllers/ProjectController.php
namespace App\Http\Controllers;

use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;

class ProjectController extends Controller
{
    public function index(Request $request)
    {
        $projects = Project::visibleTo($request->user())
            ->with('client:id,name,company')
            ->withCount(['tasks', 'tasks as done_tasks_count' => fn ($q) => $q->where('done', true)])
            ->latest()
            ->paginate(12);

        return view('projects.index', compact('projects'));
    }

    public function show(Project $project)
    {
        Gate::authorize('view', $project);

        $project->load(['client', 'tasks', 'attachments.user']);

        return view('projects.show', compact('project'));
    }
}

CRUD del admin:

php
<?php

// app/Http/Controllers/Admin/ProjectController.php
namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Http\Requests\ProjectRequest;
use App\Models\Project;
use App\Models\User;
use App\Notifications\ProjectStatusChanged;

class ProjectController extends Controller
{
    public function create()
    {
        return view('admin.projects.form', [
            'project' => new Project(['status' => 'active']),
            'clients' => User::where('role', 'client')->orderBy('name')->get(),
        ]);
    }

    public function store(ProjectRequest $request)
    {
        $project = Project::create($request->validated());

        return redirect()->route('projects.show', $project)->with('status', 'Proyecto creado.');
    }

    public function edit(Project $project)
    {
        return view('admin.projects.form', [
            'project' => $project,
            'clients' => User::where('role', 'client')->orderBy('name')->get(),
        ]);
    }

    public function update(ProjectRequest $request, Project $project)
    {
        $project->fill($request->validated());
        $statusChanged = $project->isDirty('status'); // ¿cambió el estado?
        $project->save();

        // Avisar al cliente por correo (encolado)
        if ($statusChanged) {
            $project->client->notify(new ProjectStatusChanged($project));
        }

        return redirect()->route('projects.show', $project)->with('status', 'Proyecto actualizado.');
    }

    public function destroy(Project $project)
    {
        // Borra los archivos uno por uno para disparar el evento "deleted"
        $project->attachments->each->delete();
        $project->delete();

        return redirect()->route('projects.index')->with('status', 'Proyecto eliminado.');
    }
}

Tareas (sólo admin):

php
<?php

// app/Http/Controllers/TaskController.php
namespace App\Http\Controllers;

use App\Models\Project;
use App\Models\Task;
use Illuminate\Http\Request;

class TaskController extends Controller
{
    public function store(Request $request, Project $project)
    {
        $data = $request->validate(['title' => ['required', 'string', 'max:200']]);

        $project->tasks()->create($data + ['position' => $project->tasks()->max('position') + 1]);

        return back();
    }

    public function toggle(Task $task)
    {
        $task->update(['done' => ! $task->done]);

        return back();
    }

    public function destroy(Task $task)
    {
        $task->delete();

        return back();
    }
}

Archivos (privados, con descarga protegida):

php
<?php

// app/Http/Controllers/AttachmentController.php
namespace App\Http\Controllers;

use App\Models\Attachment;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;

class AttachmentController extends Controller
{
    public function store(Request $request, Project $project)
    {
        Gate::authorize('upload', $project);

        $request->validate([
            'file' => ['required', 'file', 'max:10240', // 10 MB
                       'mimes:pdf,jpg,jpeg,png,webp,zip,docx,xlsx,txt'],
        ]);

        $file = $request->file('file');

        $project->attachments()->create([
            'user_id'       => $request->user()->id,
            'original_name' => $file->getClientOriginalName(),
            'path'          => $file->store("projects/{$project->id}", 'local'),
            'size'          => $file->getSize(),
        ]);

        return back()->with('status', 'Archivo subido.');
    }

    public function download(Attachment $attachment)
    {
        Gate::authorize('view', $attachment->project);

        return Storage::disk('local')->download($attachment->path, $attachment->original_name);
    }

    public function destroy(Attachment $attachment)
    {
        // Sólo admin o quien lo subió
        abort_unless(request()->user()->isAdmin() || $attachment->user_id === request()->user()->id, 403);

        $attachment->delete(); // el modelo borra el archivo físico

        return back()->with('status', 'Archivo eliminado.');
    }
}

Paso 7: rutas ​

php
<?php

// routes/web.php
use App\Http\Controllers\Admin;
use App\Http\Controllers\AttachmentController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;

Route::redirect('/', '/projects');

Route::middleware('auth')->group(function () {
    Route::redirect('/dashboard', '/projects')->name('dashboard');

    Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
    Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');

    Route::post('/projects/{project}/attachments', [AttachmentController::class, 'store'])->name('attachments.store');
    Route::get('/attachments/{attachment}', [AttachmentController::class, 'download'])->name('attachments.download');
    Route::delete('/attachments/{attachment}', [AttachmentController::class, 'destroy'])->name('attachments.destroy');

    // Sólo administradores
    Route::middleware('admin')->prefix('admin')->name('admin.')->group(function () {
        Route::resource('projects', Admin\ProjectController::class)->except(['index', 'show']);
        Route::post('projects/{project}/tasks', [TaskController::class, 'store'])->name('tasks.store');
        Route::patch('tasks/{task}/toggle', [TaskController::class, 'toggle'])->name('tasks.toggle');
        Route::delete('tasks/{task}', [TaskController::class, 'destroy'])->name('tasks.destroy');
    });

    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});

require __DIR__.'/auth.php';

Buena práctica

El registro público no tiene sentido aquí: los clientes los crea la agencia. En routes/auth.php elimina (o comenta) las rutas register.

Paso 8: vistas principales ​

blade
{{-- resources/views/projects/index.blade.php --}}
<x-app-layout>
    <x-slot name="header">
        <div class="flex justify-between items-center">
            <h2 class="font-semibold text-xl">Proyectos</h2>
            @can('create', App\Models\Project::class)
                <a href="{{ route('admin.projects.create') }}" class="px-4 py-2 bg-indigo-600 text-white rounded">
                    Nuevo proyecto
                </a>
            @endcan
        </div>
    </x-slot>

    <div class="max-w-7xl mx-auto p-6">
        @if (session('status'))
            <div class="mb-4 p-3 bg-green-100 text-green-800 rounded">{{ session('status') }}</div>
        @endif

        <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
            @forelse ($projects as $project)
                <a href="{{ route('projects.show', $project) }}" class="block bg-white p-5 rounded shadow hover:shadow-md">
                    <h3 class="font-bold">{{ $project->name }}</h3>
                    @if (auth()->user()->isAdmin())
                        <p class="text-sm text-gray-500">{{ $project->client->company ?? $project->client->name }}</p>
                    @endif
                    <p class="text-sm mt-2">{{ App\Models\Project::STATUSES[$project->status] }}</p>

                    {{-- Barra de progreso --}}
                    <div class="mt-3 h-2 bg-gray-200 rounded">
                        <div class="h-2 bg-indigo-600 rounded" style="width: {{ $project->progress() }}%"></div>
                    </div>
                    <p class="text-xs text-gray-500 mt-1">{{ $project->progress() }}% completado</p>
                </a>
            @empty
                <p>Aún no hay proyectos.</p>
            @endforelse
        </div>

        <div class="mt-6">{{ $projects->links() }}</div>
    </div>
</x-app-layout>
blade
{{-- resources/views/projects/show.blade.php --}}
<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl">{{ $project->name }}</h2>
    </x-slot>

    <div class="max-w-5xl mx-auto p-6 space-y-6">
        @if (session('status'))
            <div class="p-3 bg-green-100 text-green-800 rounded">{{ session('status') }}</div>
        @endif

        <section class="bg-white p-5 rounded shadow">
            <p>{{ $project->description }}</p>
            <p class="mt-2 text-sm">Estado: <strong>{{ App\Models\Project::STATUSES[$project->status] }}</strong></p>
            @if ($project->due_date)
                <p class="text-sm">Entrega: {{ $project->due_date->format('d/m/Y') }}</p>
            @endif
            @can('update', $project)
                <a href="{{ route('admin.projects.edit', $project) }}" class="text-indigo-600 text-sm">Editar proyecto</a>
            @endcan
        </section>

        <section class="bg-white p-5 rounded shadow">
            <h3 class="font-bold mb-3">Tareas ({{ $project->progress() }}%)</h3>
            <ul class="space-y-2">
                @foreach ($project->tasks as $task)
                    <li class="flex items-center gap-3">
                        @if (auth()->user()->isAdmin())
                            <form method="POST" action="{{ route('admin.tasks.toggle', $task) }}">
                                @csrf @method('PATCH')
                                <button>{{ $task->done ? '✅' : '⬜' }}</button>
                            </form>
                        @else
                            <span>{{ $task->done ? '✅' : '⬜' }}</span>
                        @endif
                        <span class="{{ $task->done ? 'line-through text-gray-400' : '' }}">{{ $task->title }}</span>
                    </li>
                @endforeach
            </ul>

            @if (auth()->user()->isAdmin())
                <form method="POST" action="{{ route('admin.tasks.store', $project) }}" class="mt-4 flex gap-2">
                    @csrf
                    <input name="title" placeholder="Nueva tarea" class="border rounded px-2 flex-1" required>
                    <button class="px-3 bg-indigo-600 text-white rounded">Agregar</button>
                </form>
            @endif
        </section>

        <section class="bg-white p-5 rounded shadow">
            <h3 class="font-bold mb-3">Archivos</h3>
            <ul class="divide-y">
                @forelse ($project->attachments as $file)
                    <li class="py-2 flex justify-between">
                        <a href="{{ route('attachments.download', $file) }}" class="text-indigo-600">{{ $file->original_name }}</a>
                        <span class="text-xs text-gray-500">
                            {{ number_format($file->size / 1024, 0) }} KB · {{ $file->user->name }} · {{ $file->created_at->diffForHumans() }}
                        </span>
                    </li>
                @empty
                    <li class="py-2 text-gray-500">Sin archivos.</li>
                @endforelse
            </ul>

            @can('upload', $project)
                <form method="POST" action="{{ route('attachments.store', $project) }}" enctype="multipart/form-data" class="mt-4">
                    @csrf
                    <input type="file" name="file" required>
                    <button class="px-3 py-1 bg-indigo-600 text-white rounded">Subir</button>
                    @error('file') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
                </form>
            @endcan
        </section>
    </div>
</x-app-layout>

El formulario admin/projects/form.blade.php es un formulario normal con client_id (select), name, description, status y due_date, usando old('campo', $project->campo); su action cambia según $project->exists (store con POST o update con @method('PUT')). Queda como parte del reto.

Paso 9: notificación por correo ​

bash
php artisan make:notification ProjectStatusChanged
php
<?php

namespace App\Notifications;

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

class ProjectStatusChanged extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public Project $project) {}

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

    public function toMail(object $notifiable): MailMessage
    {
        $estado = Project::STATUSES[$this->project->status];

        return (new MailMessage)
            ->subject("Tu proyecto \"{$this->project->name}\" ahora está: {$estado}")
            ->greeting("Hola, {$notifiable->name}")
            ->line("El estado de tu proyecto cambió a **{$estado}**.")
            ->action('Ver proyecto', route('projects.show', $this->project));
    }

    public function toArray(object $notifiable): array
    {
        return ['project_id' => $this->project->id, 'status' => $this->project->status];
    }
}

Recuerda tener un worker: php artisan queue:work.

Paso 10: API para el cliente ​

php
<?php

// app/Http/Resources/ProjectResource.php
namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ProjectResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'       => $this->id,
            'name'     => $this->name,
            'status'   => $this->status,
            'due_date' => $this->due_date?->toDateString(),
            'progress' => $this->progress(),
            'tasks'    => $this->whenLoaded('tasks', fn () => $this->tasks->map->only('id', 'title', 'done')),
        ];
    }
}
php
<?php

// routes/api.php
use App\Http\Resources\ProjectResource;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
    Route::get('/projects', fn (Request $request) => ProjectResource::collection(
        Project::visibleTo($request->user())->latest()->paginate(20)
    ));

    Route::get('/projects/{project}', function (Project $project) {
        Gate::authorize('view', $project);
        return new ProjectResource($project->load('tasks'));
    });
});

El cliente genera su token desde su perfil (añade un botón que llame a $request->user()->createToken('integracion')->plainTextToken y lo muestre una sola vez).

Paso 11: datos de prueba ​

php
<?php

// database/seeders/DatabaseSeeder.php
namespace Database\Seeders;

use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $admin = User::factory()->create(['name' => 'Agencia', 'email' => 'admin@agencia.test']);
        $admin->role = 'admin';
        $admin->save();

        User::factory(3)->create()->each(function (User $client) {
            Project::factory(2)->create(['client_id' => $client->id])->each(
                fn (Project $p) => Task::factory(5)->create(['project_id' => $p->id])
            );
        });
    }
}
php
// database/factories/ProjectFactory.php -> definition()
return [
    'client_id'   => User::factory(),
    'name'        => 'Sitio web ' . fake()->company(),
    'description' => fake()->paragraph(),
    'status'      => fake()->randomElement(['active', 'paused', 'completed']),
    'due_date'    => fake()->dateTimeBetween('now', '+3 months'),
];

// database/factories/TaskFactory.php -> definition()
return [
    'project_id' => Project::factory(),
    'title'      => fake()->randomElement(['Diseño', 'Maquetación', 'Contenido', 'SEO', 'Pruebas']),
    'done'       => fake()->boolean(),
];
bash
php artisan migrate:fresh --seed

Paso 12: tests de permisos ​

php
<?php

// tests/Feature/PortalTest.php
use App\Models\Attachment;
use App\Models\Project;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

function admin(): User
{
    $u = User::factory()->create();
    $u->role = 'admin';
    $u->save();
    return $u;
}

test('un cliente sólo ve sus proyectos', function () {
    $ana = User::factory()->create();
    Project::factory()->create(['client_id' => $ana->id, 'name' => 'Proyecto Ana']);
    Project::factory()->create(['name' => 'Proyecto Ajeno']);

    $this->actingAs($ana)->get('/projects')
        ->assertSee('Proyecto Ana')
        ->assertDontSee('Proyecto Ajeno');
});

test('un cliente no puede abrir un proyecto ajeno', function () {
    $project = Project::factory()->create();

    $this->actingAs(User::factory()->create())
        ->get(route('projects.show', $project))
        ->assertForbidden();
});

test('un cliente no puede crear proyectos', function () {
    $this->actingAs(User::factory()->create())
        ->get(route('admin.projects.create'))
        ->assertForbidden();
});

test('el admin crea un proyecto', function () {
    $client = User::factory()->create();

    $this->actingAs(admin())->post(route('admin.projects.store'), [
        'client_id' => $client->id,
        'name'      => 'Landing page',
        'status'    => 'active',
    ])->assertRedirect();

    $this->assertDatabaseHas('projects', ['name' => 'Landing page', 'client_id' => $client->id]);
});

test('el cliente sube y descarga un archivo de su proyecto', function () {
    Storage::fake('local');
    $client  = User::factory()->create();
    $project = Project::factory()->create(['client_id' => $client->id, 'status' => 'active']);

    $this->actingAs($client)->post(route('attachments.store', $project), [
        'file' => UploadedFile::fake()->create('logo.pdf', 200, 'application/pdf'),
    ])->assertSessionHasNoErrors();

    $attachment = Attachment::first();
    Storage::disk('local')->assertExists($attachment->path);

    $this->actingAs($client)->get(route('attachments.download', $attachment))->assertOk();
    $this->actingAs(User::factory()->create())->get(route('attachments.download', $attachment))->assertForbidden();
});

test('la API devuelve sólo los proyectos del cliente', function () {
    $client = User::factory()->create();
    Project::factory(2)->create(['client_id' => $client->id]);
    Project::factory(3)->create();

    Laravel\Sanctum\Sanctum::actingAs($client);

    $this->getJson('/api/v1/projects')->assertOk()->assertJsonCount(2, 'data');
});
bash
php artisan test

Paso 13: desplegar ​

Sigue la lección de despliegue: .env de producción, npm run build, migrate --force, cron del scheduler y un worker para las notificaciones. Antes de entregar, pasa el checklist de seguridad.

Errores comunes en este proyecto

  • Poner role en $fillable: un cliente podría hacerse admin editando su perfil.
  • Guardar los archivos en el disco public: cualquiera con la URL podría descargar documentos de otros clientes.
  • Olvidar el worker: las notificaciones se quedan en la tabla jobs y nunca salen.
  • Consultas N+1 en el listado: usa with() y withCount() como en ProjectController@index.

Retos para llevarlo a nivel profesional ​

  1. Completa admin/projects/form.blade.php y un CRUD de clientes en /admin/clients (al crear un cliente, envíale un enlace para establecer su contraseña con Password::sendResetLink).
  2. Agrega comentarios por proyecto (cliente y agencia conversan) con notificación al otro lado.
  3. Muestra las notificaciones no leídas con un contador en la barra de navegación.
Ver pista del reto 1
php
// Admin\ClientController@store
$data = $request->validate([
    'name'    => ['required', 'max:255'],
    'company' => ['nullable', 'max:255'],
    'email'   => ['required', 'email', 'unique:users,email'],
]);

$client = User::create($data + ['password' => Str::random(32)]); // contraseña aleatoria temporal

// Envía el correo estándar de "restablecer contraseña"
\Illuminate\Support\Facades\Password::sendResetLink(['email' => $client->email]);

return redirect()->route('admin.clients.index')->with('status', 'Cliente creado e invitado por correo.');
Ver pista del reto 3
blade
{{-- En layouts/navigation.blade.php --}}
@php($pendientes = auth()->user()->unreadNotifications()->count())
<a href="{{ route('notifications.index') }}" class="relative">
    🔔
    @if ($pendientes)
        <span class="absolute -top-1 -right-2 bg-red-600 text-white text-xs rounded-full px-1">{{ $pendientes }}</span>
    @endif
</a>
php
// Ruta y marcado como leídas
Route::get('/notifications', function (Request $request) {
    $notifications = $request->user()->notifications()->paginate(20);
    $request->user()->unreadNotifications->markAsRead();
    return view('notifications.index', compact('notifications'));
})->middleware('auth')->name('notifications.index');

Resumen ​

  • Construiste un portal de clientes real con roles, proyectos, tareas y archivos privados.
  • Autorización en tres capas: middleware admin, ProjectPolicy y scope visibleTo.
  • Archivos privados en el disco local con descarga a través de un controlador autorizado.
  • Notificaciones encoladas al cambiar el estado, API versionada con Sanctum y tests de permisos.
  • Este proyecto es una excelente pieza de portafolio: súbelo a GitHub con un README y capturas.

Academia Web — aprende, copia y construye.