CRUD com Laravel 12, Breeze e Fortify – Parte 5B

Na Parte 5A, definimos a estrutura do banco de dados e os arquivos essenciais de modelagem (Model, Migration e Factory) para a entidade Task. Agora, na Parte 5B, vamos finalizar o CRUD Laravel 12: criando as rotas, o TaskController, as views com Blade e Tailwind CSS, e conectando tudo com os métodos de armazenamento, edição e exclusão.

Este é o momento onde o sistema começa a ganhar forma visual e funcional.

CRUD Laravel 12

1. Criando o Controller de Tarefas

Use o Artisan para gerar um controller com os métodos básicos para o CRUD:

php artisan make:controller TaskController --resource

Este comando cria o TaskController com os métodos:

  • index()
  • create()
  • store()
  • show()
  • edit()
  • update()
  • destroy()

Abra o arquivo app/Http/Controllers/TaskController.php e edite conforme abaixo:

<?php

namespace App\Http\Controllers;

use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;

class TaskController extends Controller
{
    use AuthorizesRequests;

    public function index()
    {
        $tasks = Task::where('user_id', Auth::id())->latest()->get();
        return view('tasks.index', compact('tasks'));
    }

    public function create()
    {
        return view('tasks.create');
    }

    public function store(Request $request)
    {
        $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
        ]);

        Task::create([
            'user_id' => Auth::id(),
            'title' => $request->title,
            'description' => $request->description,
            'is_completed' => false,
        ]);

        return redirect()->route('tasks.index')->with('success', 'Task created successfully!');
    }

    public function edit(Task $task)
    {
        $this->authorize('update', $task);
        return view('tasks.edit', compact('task'));
    }

    public function update(Request $request, Task $task)
    {
        $this->authorize('update', $task);

        $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'is_completed' => 'nullable|boolean',
        ]);

        $task->update([
            'title' => $request->title,
            'description' => $request->description,
            'is_completed' => $request->has('is_completed'),
        ]);

        return redirect()->route('tasks.index')->with('success', 'Task updated successfully!');
    }

    public function destroy(Task $task)
    {
        $this->authorize('delete', $task);
        $task->delete();
        return redirect()->route('tasks.index')->with('success', 'Task deleted!');
    }
}

2. Protegendo com Policies

Nós precisamos criar de uma política para a Task, pois queremos garantir a segurança e a integridade dos dados. O objetivo é que apenas o usuário que criou uma tarefa possa editá-la ou excluí-la. A política da Task será o local centralizado onde definiremos essa regra: ela receberá o usuário logado e a tarefa em questão, e então decidirá se o ID do usuário logado corresponde ao user_id da tarefa. Se não corresponder, a política dirá “não”, e o Laravel automaticamente dispara o erro 403, protegendo sua aplicação de acessos indevidos.

a) Criar a TaskPolicy

Primeiro, você precisa criar um arquivo de política para a sua tarefa. Abra o terminal na raiz do seu projeto Laravel e execute o seguinte comando:

php artisan make:policy TaskPolicy --model=Task

Este comando irá criar um novo arquivo em app/Policies/TaskPolicy.php.

b) Registrar a TaskPolicy no AuthServiceProvider

Em seguida, você precisa registrar essa nova política para que o Laravel saiba que ela existe e a associe ao seu modelo Task.

  1. Abra o arquivo: app/Providers/AuthServiceProvider.php
  2. Dentro da classe AuthServiceProvider, você verá uma propriedade protegida $policies (caso não exista, crie). Adicione a sua Task e TaskPolicy a este array. Certifique-se de adicionar os use statements no topo do arquivo também.
  3. Seu AuthServiceProvider.php deve ficar parecido com isto:
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Models\Task; 
use App\Policies\TaskPolicy;

class AppServiceProvider extends ServiceProvider
{
    /**
     * The model to policy mappings for the application.
     *
     * @var array<class-string, class-string>
     */
    protected $policies = [
        Task::class => TaskPolicy::class,
    ];
    
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        //

    }

}

Passo 3: Implementar a Lógica na TaskPolicy

Agora, você precisa adicionar a lógica de autorização dentro do arquivo app/Policies/TaskPolicy.php. Este arquivo já deve ter métodos “esqueletos” criados pelo comando make:policy.

A lógica principal para update e delete será verificar se o id do usuário logado ($user->id) é o mesmo que o user_id da tarefa ($task->user_id).

Substitua o conteúdo do seu app/Policies/TaskPolicy.php por este:

<?php

namespace App\Policies;

use App\Models\User;
use App\Models\Task; // Importe o modelo Task
use Illuminate\Auth\Access\Response;

class TaskPolicy
{
    /**
     * Determine whether the user can view any models.
     */
    public function viewAny(User $user): bool
    {
        // Qualquer usuário autenticado pode ver a lista de tarefas
        return $user !== null;
    }

    /**
     * Determine whether the user can view the model.
     */
    public function view(User $user, Task $task): bool
    {
        // O usuário pode ver a tarefa se for o proprietário dela
        return $user->id === $task->user_id;
    }

    /**
     * Determine whether the user can create models.
     */
    public function create(User $user): bool
    {
        // Qualquer usuário autenticado pode criar tarefas
        return $user !== null;
    }

    /**
     * Determine whether the user can update the model.
     */
    public function update(User $user, Task $task): bool
    {
        // O usuário pode atualizar a tarefa se for o proprietário dela
        return $user->id === $task->user_id;
    }

    /**
     * Determine whether the user can delete the model.
     */
    public function delete(User $user, Task $task): bool
    {
        // O usuário pode deletar a tarefa se for o proprietário dela
        return $user->id === $task->user_id;
    }

    /**
     * Determine whether the user can restore the model.
     */
    public function restore(User $user, Task $task): bool
    {
        // O usuário pode restaurar a tarefa se for o proprietário dela
        return $user->id === $task->user_id;
    }

    /**
     * Determine whether the user can permanently delete the model.
     */
    public function forceDelete(User $user, Task $task): bool
    {
        // O usuário pode forçar a exclusão da tarefa se for o proprietário dela
        return $user->id === $task->user_id;
    }
}

3. Definindo as Rotas no web.php

Agora, registre o recurso de rotas no seu routes/web.php, dentro do grupo protegido:

use App\Http\Controllers\TaskController;

Route::middleware(['auth', 'verified'])->group(function () {
    Route::resource('tasks', TaskController::class)->except(['show']);
});

Isso cria automaticamente as rotas RESTful padrão para o CRUD de tarefas.


4. Criando as Views com Blade e Tailwind

Crie a pasta resources/views/tasks e dentro dela os arquivos:

a) index.blade.php

<x-app-layout>
    <x-slot name="header">
        <div class="flex justify-between items-center">
            <h2 class="font-semibold text-xl text-gray-800 leading-tight">
                {{ __('My Tasks') }}
            </h2>
            <a href="{{ route('tasks.create') }}" class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 focus:bg-gray-700 active:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150">
                {{ __('New Task') }}
            </a>
        </div>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">

            {{-- Success Message (Toast) --}}
            @if (session('success'))
                <div class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4 mb-6 rounded-md shadow-sm" role="alert">
                    <p class="font-bold">{{ __('Success!') }}</p>
                    <p>{{ session('success') }}</p>
                </div>
            @endif

            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                <div class="p-6 text-gray-900">

                    @forelse ($tasks as $task)
                        <div class="flex items-center justify-between border-b border-gray-200 py-4 last:border-b-0">
                            <div class="flex items-center">
                                <span class="text-lg {{ $task->is_completed ? 'line-through text-gray-500' : 'text-gray-900' }}">
                                    {{ $task->title }}
                                </span>
                                @if ($task->is_completed)
                                    <span class="ml-2 px-2 py-0.5 bg-green-100 text-green-800 text-xs font-semibold rounded-full">{{ __('Completed') }}</span>
                                    <span class="ml-2 text-sm text-gray-500">({{ __('Finished on:') }} {{ $task->updated_at->format('d/m/Y H:i') }})</span>
                                @else
                                    <span class="ml-2 px-2 py-0.5 bg-yellow-100 text-yellow-800 text-xs font-semibold rounded-full">{{ __('Open') }}</span>
                                @endif
                            </div>
                            <div class="flex items-center space-x-3">
                                <a href="{{ route('tasks.edit', $task) }}" class="text-sm text-indigo-600 hover:text-indigo-900 font-medium">
                                    {{ __('Edit') }}
                                </a>
                                <form action="{{ route('tasks.destroy', $task) }}" method="POST" onsubmit="return confirm('{{ __('Are you sure you want to delete this task?') }}');">
                                    @csrf
                                    @method('DELETE')
                                    <button type="submit" class="text-sm text-red-600 hover:text-red-900 font-medium">
                                        {{ __('Delete') }}
                                    </button>
                                </form>
                            </div>
                        </div>
                    @empty
                        <p class="text-gray-600 text-center py-4">{{ __('No tasks found. How about creating one?') }}</p>
                    @endforelse

                </div>
            </div>
        </div>
    </div>
</x-app-layout>

b) create.blade.php e edit.blade.php

Os dois arquivos são muito semelhantes. Crie o create.blade.php:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Create New Task') }}
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                <div class="p-6 text-gray-900">

                    <form method="POST" action="{{ route('tasks.store') }}">
                        @csrf

                        <div>
                            <x-input-label for="title" :value="__('Task Title')" />
                            <x-text-input id="title" class="block mt-1 w-full" type="text" name="title" :value="old('title')" required autofocus />
                            <x-input-error :messages="$errors->get('title')" class="mt-2" />
                        </div>

                        <div class="mt-4">
                            <x-input-label for="description" :value="__('Description (Optional)')" />
                            <textarea id="description" name="description" rows="3" class="block mt-1 w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">{{ old('description') }}</textarea>
                            <x-input-error :messages="$errors->get('description')" class="mt-2" />
                        </div>

                        <div class="flex items-center justify-end mt-6 space-x-8">
                            <a href="{{ route('tasks.index') }}" class="text-gray-600 hover:text-gray-900">
                                {{ __('Cancel') }}
                            </a>
                            <x-primary-button>
                                {{ __('Save Task') }}
                            </x-primary-button>
                        </div>
                    </form>

                </div>
            </div>
        </div>
    </div>
</x-app-layout>

E o edit.blade.php:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Edit Task') }}
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                <div class="p-6 text-gray-900">

                    <form method="POST" action="{{ route('tasks.update', $task) }}">
                        @csrf
                        @method('PATCH') {{-- Or @method('PUT') --}}

                        <div>
                            <x-input-label for="title" :value="__('Task Title')" />
                            <x-text-input id="title" class="block mt-1 w-full" type="text" name="title" :value="old('title', $task->title)" required autofocus />
                            <x-input-error :messages="$errors->get('title')" class="mt-2" />
                        </div>

                        <div class="mt-4">
                            <x-input-label for="description" :value="__('Description (Optional)')" />
                            <textarea id="description" name="description" rows="3" class="block mt-1 w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">{{ old('description', $task->description) }}</textarea>
                            <x-input-error :messages="$errors->get('description')" class="mt-2" />
                        </div>

                        <div class="mt-4">
                            <label for="is_completed" class="inline-flex items-center">
                                <input id="is_completed" type="checkbox" name="is_completed" value="1" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" {{ old('is_completed', $task->is_completed) ? 'checked' : '' }}>
                                <span class="ml-2 text-sm text-gray-600">{{ __('Completed?') }}</span>
                            </label>
                            <x-input-error :messages="$errors->get('is_completed')" class="mt-2" />
                        </div>

                        <div class="flex items-center justify-end mt-6">
                            <a href="{{ route('tasks.index') }}" class="text-gray-600 hover:text-gray-900 mr-4">
                                {{ __('Cancel') }}
                            </a>
                            <x-primary-button>
                                {{ __('Update Task') }}
                            </x-primary-button>
                        </div>
                    </form>

                </div>
            </div>
        </div>
</x-app-layout>

Conclusão

Com isso, finalizamos o CRUD completo da entidade Task. Agora você pode listar, criar, editar e excluir tarefas com autenticação, controle de acesso e uma interface moderna com Tailwind CSS. O projeto já se comporta como um sistema real de gerenciamento de tarefas.

Na Parte 6, vamos fazer algumas melhorias no nosso CRUD.

🔗 Leitura recomendada: CRUD com Laravel 12, Breeze e Fortify – Parte 5A.