Has llegado al final de la serie. En los 5 posts anteriores aprendiste instalación, rutas, vistas, controllers y models. Ahora vamos a aplicar TODO ese conocimiento construyendo un blog completo desde cero.
¿Qué vamos a construir?
Un blog funcional con estas features:
- Autenticación de usuarios (login/registro)
- CRUD completo de posts (crear, leer, actualizar, eliminar)
- Sistema de comentarios
- Autorización (solo el autor puede editar su post)
- Búsqueda de posts
- Paginación
- Estados: Published/Draft
Stack Tecnológico
- Laravel 13: Framework principal
- Blade: Motor de plantillas
- Breeze: Kit de autenticación
- SQLite: Base de datos (fácil para desarrollo)
- Tailwind CSS: Styling (incluido con Breeze)
Paso 1: Crear el Proyecto
Abre tu terminal y ejecuta:
laravel new blog-simple --no-starter-kit
cd blog-simple
El flag --no-starter-kit evita el wizard interactivo del instalador de Laravel 13. Instalamos Breeze manualmente en el Paso 3.
O usando Composer (también válido):
composer create-project laravel/laravel blog-simple
cd blog-simple
Paso 2: Configurar la Base de Datos
Para este tutorial usaremos SQLite (no requiere instalación):
touch database/database.sqlite
Edita .env:
DB_CONNECTION=sqlite
// Comenta o elimina estas líneas:
// DB_HOST=127.0.0.1
// DB_PORT=3306
// DB_DATABASE=laravel
Ejecuta las migrations iniciales:
php artisan migrate
SQLite es perfecto para desarrollo y prototipos. Para producción, considera MySQL o PostgreSQL.
Paso 3: Instalar Laravel Breeze
Laravel Breeze provee autenticación lista para usar (login, registro, recuperación de contraseña):
composer require laravel/breeze --dev
php artisan breeze:install blade
Te preguntará si quieres dark mode y testing. Responde según prefieras:
Would you like dark mode support? (yes/no) [no]:
> no
Would you like to install Pest for testing? (yes/no) [yes]:
> yes
Instala las dependencias de npm y compila assets:
npm install
npm run dev
Ejecuta las nuevas migrations de Breeze:
php artisan migrate
Levanta el servidor:
php artisan serve
Visita http://127.0.0.1:8000 y verás links de Login y Register en el header.
Paso 4: Crear el Model Post con Migration
php artisan make:model Post -m
Edita la migration (database/migrations/xxxx_create_posts_table.php):
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->text('content');
$table->boolean('published')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
}
Ejecuta la migration:
php artisan migrate
Paso 5: Configurar el Model Post
Edita app/Models/Post.php:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Post extends Model
{
protected $fillable = [
'title',
'content',
'published',
'published_at',
];
protected $casts = [
'published' => 'boolean',
'published_at' => 'datetime',
];
// Relación: Un post pertenece a un usuario
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Y actualiza app/Models/User.php:
use Illuminate\Database\Eloquent\Relations\HasMany;
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
Paso 6: Crear el PostController
php artisan make:controller PostController --resource
Edita app/Http/Controllers/PostController.php:
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class PostController extends Controller
{
public function __construct()
{
// Requiere autenticación excepto para index y show
$this->middleware('auth')->except(['index', 'show']);
}
public function index()
{
$posts = Post::with('user')
->where('published', true)
->latest()
->paginate(10);
return view('posts.index', compact('posts'));
}
public function create()
{
return view('posts.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|max:255',
'content' => 'required',
'published' => 'boolean',
]);
$post = Auth::user()->posts()->create([
'title' => $validated['title'],
'content' => $validated['content'],
'published' => $request->has('published'),
'published_at' => $request->has('published') ? now() : null,
]);
return redirect()
->route('posts.show', $post)
->with('success', 'Post creado exitosamente!');
}
public function show(Post $post)
{
// Solo mostrar posts publicados o del autor
if (!$post->published && $post->user_id !== Auth::id()) {
abort(404);
}
return view('posts.show', compact('post'));
}
public function edit(Post $post)
{
// Autorización: solo el autor puede editar
if ($post->user_id !== Auth::id()) {
abort(403);
}
return view('posts.edit', compact('post'));
}
public function update(Request $request, Post $post)
{
if ($post->user_id !== Auth::id()) {
abort(403);
}
$validated = $request->validate([
'title' => 'required|max:255',
'content' => 'required',
'published' => 'boolean',
]);
$post->update([
'title' => $validated['title'],
'content' => $validated['content'],
'published' => $request->has('published'),
'published_at' => $request->has('published') ? ($post->published_at ?? now()) : null,
]);
return redirect()
->route('posts.show', $post)
->with('success', 'Post actualizado!');
}
public function destroy(Post $post)
{
if ($post->user_id !== Auth::id()) {
abort(403);
}
$post->delete();
return redirect()
->route('posts.index')
->with('success', 'Post eliminado!');
}
}
Route Model Binding en acción: Laravel automáticamente busca el Post por ID en la URL. Si no existe, retorna 404.
Paso 7: Definir las Rutas
Edita routes/web.php:
<?php
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return redirect()->route('posts.index');
});
Route::resource('posts', PostController::class);
require __DIR__.'/auth.php';
Paso 8: Crear las Vistas
Layout principal (resources/views/layouts/app.blade.php) ya viene con Breeze.
Vista Index - Crea resources/views/posts/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">
Blog Posts
</h2>
@auth
<a href="{{ route('posts.create') }}" class="bg-blue-500 text-white px-4 py-2 rounded">
Nuevo Post
</a>
@endauth
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
@if (session('success'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{{ session('success') }}
</div>
@endif
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900">
@forelse($posts as $post)
<article class="mb-8 pb-8 border-b last:border-0">
<h2 class="text-2xl font-bold mb-2">
<a href="{{ route('posts.show', $post) }}" class="hover:text-blue-600">
{{ $post->title }}
</a>
</h2>
<p class="text-gray-600 text-sm mb-4">
Por {{ $post->user->name }} • {{ $post->published_at->diffForHumans() }}
</p>
<p class="text-gray-700 mb-4">
{{ Str::limit($post->content, 200) }}
</p>
<a href="{{ route('posts.show', $post) }}" class="text-blue-600 hover:underline">
Leer más →
</a>
</article>
@empty
<p class="text-gray-500">No hay posts publicados aún.</p>
@endforelse
<div class="mt-6">
{{ $posts->links() }}
</div>
</div>
</div>
</div>
</div>
</x-app-layout>
Vista Create - Crea resources/views/posts/create.blade.php:
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Crear Nuevo Post
</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 action="{{ route('posts.store') }}" method="POST">
@csrf
<div class="mb-4">
<label for="title" class="block text-gray-700 font-bold mb-2">Título</label>
<input
type="text"
name="title"
id="title"
value="{{ old('title') }}"
class="w-full border-gray-300 rounded-md shadow-sm"
required
>
@error('title')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="content" class="block text-gray-700 font-bold mb-2">Contenido</label>
<textarea
name="content"
id="content"
rows="10"
class="w-full border-gray-300 rounded-md shadow-sm"
required
>{{ old('content') }}</textarea>
@error('content')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label class="flex items-center">
<input type="checkbox" name="published" value="1" class="mr-2">
<span class="text-gray-700">Publicar inmediatamente</span>
</label>
</div>
<div class="flex gap-4">
<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
Crear Post
</button>
<a href="{{ route('posts.index') }}" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">
Cancelar
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>
Vista Show - Crea resources/views/posts/show.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">
{{ $post->title }}
</h2>
@can('update', $post)
<div class="flex gap-2">
<a href="{{ route('posts.edit', $post) }}" class="bg-yellow-500 text-white px-4 py-2 rounded">
Editar
</a>
<form action="{{ route('posts.destroy', $post) }}" method="POST" onsubmit="return confirm('¿Estás seguro?')">
@csrf
@method('DELETE')
<button type="submit" class="bg-red-500 text-white px-4 py-2 rounded">
Eliminar
</button>
</form>
</div>
@endcan
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
@if (session('success'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{{ session('success') }}
</div>
@endif
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900">
<p class="text-gray-600 text-sm mb-6">
Por {{ $post->user->name }} • {{ $post->published_at?->format('d/m/Y') ?? 'Borrador' }}
</p>
<div class="prose max-w-none">
{!! nl2br(e($post->content)) !!}
</div>
<div class="mt-8 pt-8 border-t">
<a href="{{ route('posts.index') }}" class="text-blue-600 hover:underline">
← Volver a todos los posts
</a>
</div>
</div>
</div>
</div>
</div>
</x-app-layout>
Vista Edit - Crea resources/views/posts/edit.blade.php:
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Editar Post
</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 action="{{ route('posts.update', $post) }}" method="POST">
@csrf
@method('PUT')
<div class="mb-4">
<label for="title" class="block text-gray-700 font-bold mb-2">Título</label>
<input
type="text"
name="title"
id="title"
value="{{ old('title', $post->title) }}"
class="w-full border-gray-300 rounded-md shadow-sm"
required
>
@error('title')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="content" class="block text-gray-700 font-bold mb-2">Contenido</label>
<textarea
name="content"
id="content"
rows="10"
class="w-full border-gray-300 rounded-md shadow-sm"
required
>{{ old('content', $post->content) }}</textarea>
@error('content')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label class="flex items-center">
<input
type="checkbox"
name="published"
value="1"
{{ old('published', $post->published) ? 'checked' : '' }}
class="mr-2"
>
<span class="text-gray-700">Publicado</span>
</label>
</div>
<div class="flex gap-4">
<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
Actualizar Post
</button>
<a href="{{ route('posts.show', $post) }}" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">
Cancelar
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>
Paso 9: Crear una Policy para Autorización
php artisan make:policy PostPolicy --model=Post
Edita app/Policies/PostPolicy.php:
<?php
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
}
Las Policies centralizan la lógica de autorización. Usa @can en Blade y $this->authorize() en controllers para aplicarlas.
Paso 10: Probar la Aplicación
- Registra un usuario: Ve a
/register - Crea un post: Click en “Nuevo Post”
- Publica el post: Marca el checkbox “Publicar”
- Edita tu post: Solo tú verás el botón “Editar”
- Cierra sesión y verás que el post sigue visible pero no puedes editarlo
Características Adicionales (Opcionales)
1. Búsqueda de Posts
Agrega un método search al controller:
public function index(Request $request)
{
$query = Post::with('user')->where('published', true);
if ($search = $request->input('search')) {
$query->where(function($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('content', 'like', "%{$search}%");
});
}
$posts = $query->latest()->paginate(10);
return view('posts.index', compact('posts'));
}
Y agrega un formulario de búsqueda en index.blade.php.
2. Comentarios
Crea un model Comment con relación a Post y User.
3. Categorías
Crea un model Category con relación Many-to-Many con Post.
Deploy a Producción
Opciones de hosting:
- Laravel Forge - Fácil, automatizado, desde $12/mes
- Laravel Cloud - Plataforma oficial de Laravel, optimizada para Laravel
- Railway - Deploy con Git, gratis hasta cierto uso
- DigitalOcean - VPS desde $6/mes
Pasos básicos para deploy:
// 1. Configurar .env de producción
APP_ENV=production
APP_DEBUG=false
APP_URL=https://tublog.com
// 2. Optimizar
php artisan config:cache
php artisan route:cache
php artisan view:cache
// 3. Migrar database
php artisan migrate --force
// 4. Generar APP_KEY si no existe
php artisan key:generate
NUNCA hagas commit de tu archivo .env! Cada entorno debe tener su propio .env con credenciales únicas.
Next Steps: Expande tu Blog
Ahora que tienes un blog funcional, puedes agregar:
- Rich Text Editor: Integra TinyMCE o Trix
- Image Uploads: Usa Laravel Storage
- Tags: Sistema de etiquetas con Many-to-Many
- RSS Feed: Para suscriptores
- API: Exponer posts vía JSON API
- Testing: Escribe tests con Pest/PHPUnit
- Emails: Notificaciones de nuevos comentarios
- Admin Dashboard: Panel con estadísticas
Conclusión de la Serie
Felicidades por completar la serie Aprende Laravel Fundamentals. Has aprendido:
- Instalación y Setup
- Rutas
- Vistas y Layouts
- Controllers
- Models y Database
- Proyecto Práctico (este post)
Ahora estás listo para explorar temas más avanzados como:
- Testing con Pest/PHPUnit
- APIs RESTful con Laravel Sanctum
- Queues & Jobs para tareas asíncronas
- Broadcasting con Laravel Echo
- Packages - Crea tu propio paquete
Preguntas Frecuentes
¿Necesito saber JavaScript para crear un blog con Laravel?
No. Con el Livewire Starter Kit puedes crear un blog completamente funcional usando solo PHP y Blade. Livewire proporciona reactividad sin escribir JavaScript. Sin embargo, conocer JavaScript básico te ayudará a personalizar el comportamiento.
¿Cómo protejo el panel de administración?
Usa middleware de autenticación: Route::middleware('auth')->group(function() { ... }). El Livewire Starter Kit incluye autenticación completa (login, registro, recuperar contraseña) lista para usar. También puedes usar Policies para controlar permisos granulares.
¿Cómo añado un editor WYSIWYG para los posts?
Integra un editor como TinyMCE o Trix. Para Livewire, usa wire:ignore en el contenedor del editor y despacha eventos para actualizar el modelo. Alternativamente, usa Laravel Volt + FilamentPHP que incluye un editor markdown integrado.
¿Cómo implemento categorías y tags en los posts?
Crea una relación Many-to-Many entre Posts y Categories/Tags usando una tabla pivot. Define en el model Post: public function categories() { return $this->belongsToMany(Category::class); }. Usa attach() y sync() para gestionar las relaciones.
¿Cómo añado paginación a la lista de posts?
Cambia Post::all() por Post::paginate(15). En la vista Blade, añade {{ $posts->links() }} para mostrar los controles de paginación. Laravel usa Tailwind CSS para los estilos de paginación por defecto.
¿Cómo implemento búsqueda en el blog?
Usa Post::where('title', 'like', "%{$query}%")->orWhere('content', 'like', "%{$query}%")->get(). Para búsqueda avanzada, considera Laravel Scout con Algolia/Meilisearch, o el paquete spatie/laravel-searchable para búsqueda en base de datos.
Recursos Adicionales
- Documentación oficial de Laravel
- Laracasts - Videos tutoriales (inglés)
- Laravel News - Noticias y tutoriales
- Laravel Daily - Tips diarios
Video de la lección
Ver video tutorial: Aprende Laravel - Proyecto Blog
Playlist completa en YouTube: Aprende Laravel @ YouTube
Muchas gracias por seguir esta serie. Si tienes preguntas, déjalas en los comentarios.
