Tema
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:
| Funcionalidad | Lección |
|---|---|
| Login, recuperación de contraseña | Breeze |
| Proyectos, tareas y archivos relacionados | Relaciones |
| Validación con Form Requests | Validación |
| Roles, middleware y policies | Autorización |
| Archivos privados con descarga protegida | Storage |
| Notificación por correo encolada | Colas |
| API de sólo lectura con Sanctum | API |
| Tests de permisos | Testing |
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-tableConfigura .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 migratePaso 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=Projectphp
<?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 ProjectRequestphp
<?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 AttachmentControllerPanel 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 ProjectStatusChangedphp
<?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 --seedPaso 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 testPaso 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
roleen$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
jobsy nunca salen. - Consultas N+1 en el listado: usa
with()ywithCount()como enProjectController@index.
Retos para llevarlo a nivel profesional
- Completa
admin/projects/form.blade.phpy un CRUD de clientes en/admin/clients(al crear un cliente, envíale un enlace para establecer su contraseña conPassword::sendResetLink). - Agrega comentarios por proyecto (cliente y agencia conversan) con notificación al otro lado.
- 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,ProjectPolicyy scopevisibleTo. - Archivos privados en el disco
localcon 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.