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 12: 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
cd blog-simple
O usando Composer:
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
- Vercel - Gratis para proyectos personales
- 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
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
Acá te dejo la playlist en youtube donde iré agregando los videos de la serie: Aprende Laravel @ YouTube
Muchas gracias por seguir esta serie. Si tienes preguntas, déjalas en los comentarios.
