Laravel

Aprende Laravel: Proyecto Práctico - Blog Simple

Autorangel cruz
Publicado
Lectura5 min de lectura
Actualizado
Aprende Laravel: Proyecto Práctico - Blog Simple

Laravel Blog Project

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

  1. Registra un usuario: Ve a /register
  2. Crea un post: Click en "Nuevo Post"
  3. Publica el post: Marca el checkbox "Publicar"
  4. Edita tu post: Solo tú verás el botón "Editar"
  5. 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:

  1. Laravel Forge - Fácil, automatizado, desde $12/mes
  2. Laravel Cloud - Plataforma oficial de Laravel, optimizada para Laravel
  3. Railway - Deploy con Git, gratis hasta cierto uso
  4. 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:

  1. Instalación y Setup
  2. Rutas
  3. Vistas y Layouts
  4. Controllers
  5. Models y Database
  6. 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

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.

Bio
Angel Cruz

Desarrollador web full-stack enfocado en React, buenas prácticas y código abierto. Apasionado por construir productos útiles y compartir lo aprendido en el camino.