---
title: "Aprende Laravel: Proyecto Práctico - Blog Simple"
excerpt: "Construye tu primer proyecto Laravel desde cero: un blog completo con autenticación, CRUD de posts y comentarios. Proyecto final de la serie."
date: "2026-02-14T12:00:00.000Z"
lastModified: "2026-03-29T00:00:00.000Z"
category: "Laravel"
author:
  name: "angel cruz"
  picture: "https://angelcruzdevcdn.nyc3.cdn.digitaloceanspaces.com/images/me/angel-cruz.png"
ogImage:
  url: "/images/open-graph/laravel-opengraph-image.png"
seo_title: "Cómo Crear un Blog en Laravel 13: Proyecto Práctico Paso a Paso"
seo_description: "Aprende a crear un blog completo con Laravel 13: autenticación, CRUD, relaciones, validación y deploy. Proyecto práctico paso a paso."
keywords:
  - laravel
  - proyecto laravel
  - blog laravel
  - crud laravel
  - laravel tutorial
  - laravel español
learning_path:
  series: "laravel-fundamentals"
  order: 6
  total: 6
  prev_slug: "aprende-laravel-models-database"
---

![Laravel Blog Project](https://angelcruzdevcdn.nyc3.cdn.digitaloceanspaces.com/images/laravel-banner.svg)

Has llegado al final de la serie. En los 5 posts anteriores aprendiste [instalación](/post/aprende-laravel-instalacion-setup), [rutas](/post/aprende-laravel-rutas), [vistas](/post/aprende-laravel-vistas-layouts), [controllers](/post/aprende-laravel-controllers) y [models](/post/aprende-laravel-models-database). 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:

```bash
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):

```bash
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):

```bash
touch database/database.sqlite
```

Edita `.env`:

```bash
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:

```bash
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):

```bash
composer require laravel/breeze --dev
php artisan breeze:install blade
```

Te preguntará si quieres dark mode y testing. Responde según prefieras:

```bash
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:

```bash
npm install
npm run dev
```

Ejecuta las nuevas migrations de Breeze:

```bash
php artisan migrate
```

Levanta el servidor:

```bash
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

```bash
php artisan make:model Post -m
```

Edita la migration (`database/migrations/xxxx_create_posts_table.php`):

```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:

```bash
php artisan migrate
```

### Paso 5: Configurar el Model Post

Edita `app/Models/Post.php`:

```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`:

```php
use Illuminate\Database\Eloquent\Relations\HasMany;

public function posts(): HasMany
{
    return $this->hasMany(Post::class);
}
```

### Paso 6: Crear el PostController

```bash
php artisan make:controller PostController --resource
```

Edita `app/Http/Controllers/PostController.php`:

```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
<?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`:

```blade
<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`:

```blade
<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`:

```blade
<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`:

```blade
<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

```bash
php artisan make:policy PostPolicy --model=Post
```

Edita `app/Policies/PostPolicy.php`:

```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:

```php
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:**

```bash
// 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](/post/aprende-laravel-instalacion-setup)
2. [Rutas](/post/aprende-laravel-rutas)
3. [Vistas y Layouts](/post/aprende-laravel-vistas-layouts)
4. [Controllers](/post/aprende-laravel-controllers)
5. [Models y Database](/post/aprende-laravel-models-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

- [Documentación oficial de Laravel](https://laravel.com/docs/13.x)
- [Laracasts](https://laracasts.com) - Videos tutoriales (inglés)
- [Laravel News](https://laravel-news.com) - Noticias y tutoriales
- [Laravel Daily](https://laraveldaily.com) - Tips diarios

### Video de la lección

Ver video tutorial: [Aprende Laravel - Proyecto Blog](https://www.youtube.com/watch?v=8Fv2BNGLw_8)

Playlist completa en YouTube: [Aprende Laravel @ YouTube](https://www.youtube.com/playlist?list=PLPFfjDS32gikCkR3s7pLN40MJuSlOFu6h)

Muchas gracias por seguir esta serie. Si tienes preguntas, déjalas en los comentarios.

---

## Sitemap

Índice completo del sitio: [/sitemap.md](https://angelcruz.dev/sitemap.md)

Canónico HTML: [https://angelcruz.dev/post/aprende-laravel-proyecto-blog](https://angelcruz.dev/post/aprende-laravel-proyecto-blog)
