Tutorial Laravel 13 #7: crear rutas, controladores y vistas
verificado el 7 septiembre 2026 · 9 min
Cada página recibe un controlador que consulta los modelos y devuelve una vista. Añade siempre with(['category', 'user']) a los listados: con cinco artículos, la carga anticipada baja la página de 11 consultas a 3.
Los datos ya existen y el panel de administración permite introducirlos. Este capítulo los muestra: la portada, las páginas de categoría y de etiqueta, y la ficha de un artículo.
Crear los controladores
Tres controladores solo tienen una cosa que hacer, de ahí --invokable. IndexController ya existe desde el capítulo 5: su contenido se sustituye más abajo. PostController tendrá dos métodos: mostrar un artículo y tratar la búsqueda en el capítulo siguiente.
php artisan make:controller CategoryController --invokable
php artisan make:controller TagController --invokable
php artisan make:controller PostControllerCréelos antes de escribir las rutas: una ruta de la forma Route::get('/…', CategoryController::class) exige que la clase exista, si no, ningún comando artisan arranca ya.
Las rutas
<?php
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\IndexController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\TagController;
use Illuminate\Support\Facades\Route;
Route::get('/', IndexController::class)->name('home');
Route::get('/recherche', [PostController::class, 'search'])->name('search');
Route::get('/categorie/{category}', CategoryController::class)->name('category');
Route::get('/etiquette/{tag}', TagController::class)->name('tag');
Route::get('/article/{post}', [PostController::class, 'show'])->name('post');Cada ruta tiene nombre: las vistas construyen sus enlaces con route('post', $post), nunca con una dirección escrita a mano.
Gracias a getRouteKeyName(), declarado en los modelos en el capítulo 6, los parámetros {category}, {tag} y {post} se resuelven sobre la columna slug. Laravel carga el registro y devuelve un 404 si no hay coincidencia, antes incluso de entrar en el controlador.
/recherche se declara antes que las rutas con parámetro. Si hubieras escrito Route::get('/{slug}', …) más arriba, capturaría /recherche y buscaría un artículo con ese slug. Laravel se detiene en la primera ruta que coincide: las direcciones fijas van antes que las variables.
El controlador de la portada
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;
class IndexController extends Controller
{
public function __invoke(): View
{
return view('home', [
'posts' => Post::published()
->with(['category', 'user'])
->latest('published_at')
->paginate(5),
'featuredPosts' => Post::published()->featured()
->latest('published_at')
->take(5)
->get(),
'categories' => Category::withCount('posts')->orderBy('name')->get(),
'tags' => Tag::orderBy('name')->get(),
'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
]);
}
}Hay cuatro métodos que merecen una explicación.
paginate(5)divide la lista en páginas de cinco artículos. Su funcionamiento y la manera de mostrar los enlaces se detallan en el capítulo 9.published()es el scope del capítulo 6. Sustituye awhere('is_published', true), repetido de una forma u otra en los cinco controladores.latest('published_at')ordena por fecha descendente. Sin argumento, el método ordena porcreated_at, que no es lo mismo: un artículo redactado el lunes y publicado el viernes debe aparecer en su fecha de publicación.withCount('posts')añade una columnaposts_counta cada categoría, calculada mediante una subconsulta. Es lo que la barra lateral muestra entre paréntesis, sin cargar los artículos en sí.with(['category', 'user'])es el punto importante. Lo vemos justo debajo.
El problema de las consultas N+1
La lista de artículos muestra, para cada uno, el nombre de su categoría y el de su autor. Sin precauciones, Eloquent carga primero los cinco artículos, luego busca la categoría de cada uno y después el autor de cada uno: una consulta para la lista, más diez. Con cincuenta artículos por página, ciento una consultas.
with(['category', 'user']) le pide a Eloquent que cargue las relaciones por adelantado, con una sola consulta por relación. Los registros de la aplicación lo demuestran:
select * from "posts" where "is_published" = 1 order by "published_at" desc limit 5 offset 0
select * from "categories" where "categories"."id" in (1, 2)
select * from "users" where "users"."id" in (1)Tres consultas en lugar de once, y la cifra no se mueve cuando la página crece. Es la optimización más rentable de una aplicación Laravel, y la que más a menudo se olvida.
El controlador de categoría
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;
class CategoryController extends Controller
{
public function __invoke(Category $category): View
{
return view('category', [
'category' => $category,
'posts' => $category->posts()
->published()
->with(['category', 'user'])
->latest('published_at')
->paginate(5),
'categories' => Category::withCount('posts')->orderBy('name')->get(),
'tags' => Tag::orderBy('name')->get(),
'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
]);
}
}El argumento Category $category ya es el objeto cargado: Laravel lo ha resuelto a partir del slug de la URL. No hay ninguna consulta que escribir, y el caso «categoría inexistente» se resuelve antes de entrar en el método.
$category->posts() con los paréntesis devuelve una consulta que se puede afinar. Sin ellos, $category->posts devolvería directamente la colección completa, sin filtro ni paginación. La distinción es fundamental y una fuente habitual de confusión.
El controlador de etiqueta
Exactamente el mismo esquema, con Tag:
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;
class TagController extends Controller
{
public function __invoke(Tag $tag): View
{
return view('tag', [
'tag' => $tag,
'posts' => $tag->posts()
->published()
->with(['category', 'user'])
->latest('published_at')
->paginate(5),
'categories' => Category::withCount('posts')->orderBy('name')->get(),
'tags' => Tag::orderBy('name')->get(),
'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
]);
}
}El controlador de artículo
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;
class PostController extends Controller
{
public function show(Post $post): View
{
abort_unless($post->is_published, 404);
$post->load(['category', 'user', 'tags']);
return view('post', [
'post' => $post,
'categories' => Category::withCount('posts')->orderBy('name')->get(),
'tags' => Tag::orderBy('name')->get(),
'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
]);
}
}abort_unless($post->is_published, 404) merece que nos detengamos un momento. El route model binding encuentra el artículo por su slug, esté publicado o no: sin esta línea, un borrador sería visible para cualquiera que adivine su dirección. La línea devuelve un 404 y no un 403, de modo que ni siquiera revela que el artículo existe.
load() es el equivalente de with() sobre un objeto ya cargado. Sin él, mostrar las etiquetas lanzaría una consulta adicional.
Las vistas
Las tres páginas de listado reutilizan la vista parcial escrita en el capítulo 5.
@extends('layouts.app')
@section('title', $category->name)
@section('content')
<h1 class="mb-2 text-2xl font-bold">Catégorie : {{ $category->name }}</h1>
@if ($category->description)
<p class="mb-6 text-gray-600">{{ $category->description }}</p>
@endif
@include('partials.posts-list')
@endsection@extends('layouts.app')
@section('title', $tag->name)
@section('content')
<h1 class="mb-6 text-2xl font-bold">Étiquette : {{ $tag->name }}</h1>
@include('partials.posts-list')
@endsectionLa ficha de un artículo
@extends('layouts.app')
@section('title', $post->title)
@section('description', $post->excerpt ?: Str::limit(strip_tags($post->content), 150))
@section('content')
<article class="rounded border bg-white p-6">
<h1 class="text-3xl font-bold">{{ $post->title }}</h1>
<p class="mt-2 text-sm text-gray-500">
{{ $post->user?->name }} —
<a href="http://%20route('category',%20$post-category)%20" class="hover:underline">{{ $post->category?->name }}</a>
@if ($post->published_at) — {{ $post->published_at->translatedFormat('j F Y') }} @endif
</p>
@if ($post->featured_image)
<img src="{{ Storage::url($post->featured_image) }}" alt="" class="mt-4 rounded">
@endif
<div class="prose mt-6 max-w-none">{!! $post->content !!}</div>
@if ($post->tags->isNotEmpty())
<ul class="mt-6 flex flex-wrap gap-2 text-sm">
@foreach ($post->tags as $tag)
<li><a href="http://%20route('tag',%20$tag)%20" class="rounded bg-gray-200 px-2 py-0.5">{{ $tag->name }}</a></li>
@endforeach
</ul>
@endif
</article>
@endsection{!! $post->content !!} muestra el HTML producido por el editor del panel de administración sin escaparlo. Aquí es legítimo porque el contenido viene de un administrador autenticado. No escribas nunca esta forma sobre un dato introducido por un visitante.
Storage::url() construye la dirección pública de un archivo subido. Da por supuesto que php artisan storage:link se ejecutó en el capítulo 1, si no, la imagen devolverá un 404.
Comprobar
Las cuatro páginas deben responder. Una prueba lo garantiza en cada modificación futura. El archivo completo, con el método makePost() que crea un artículo de prueba, se da en el capítulo 9, este es el método que protege los borradores:
public function test_un_article_non_publie_est_invisible(): void
{
$this->makePost(['is_published' => false]);
$this->get('/')->assertOk()->assertDontSee('Premier article');
$this->get('/article/premier-article')->assertNotFound();
}php artisan testLa prueba incluida con Laravel, tests/Feature/ExampleTest.php, carga la página de inicio sin base de datos: desde que el inicio consulta la tabla posts, falla con no such table: posts. Añádale el trait RefreshDatabase, como en las pruebas de este tutorial, o elimínela.
El capítulo siguiente añade el motor de búsqueda.