Aplicación CRUD en Laravel 12

Creación de una aplicación CRUD en Laravel 12

1. Creación del VPS

Servidor VPS en DigitalOcean.com: LEMP

Getting started after deploying LEMP

 

2. Configuración del servidor

Alojamiento para Laravel

Server Requirements

El servidor VPS debe tener instalada la versión mínima de PHP (8.2) y las extensiones necesarias.

Instalar PHP 8.4

Conexión al droplet por ssh ( con un usuario diferente a root:):

ssh usuario@alumno.me

Instalar paquetes de PHP

sudo apt update
sudo apt install curl -y
sudo apt install php php-common php-gd php-mysql php-curl php-intl php-mbstring php-bcmath php-xml php-zip -y

Server configuration for Nginx

Servidor Virtual en Nginx

sudo nano /etc/nginx/sites-available/laravel
# /etc/nginx/sites-available/laravel                     

server {
 # sustituir usuario por el nombre de usuario usado
 root /home/paco/notesApp/public;

 # Add index.php to the list if you are using PHP
 index index.php index.html;

 # sustituir alumno.me por el dominio usado
 server_name laravel.alumno.me;

 location / {
            # First attempt to serve request as file, then as directory, then f>
            # try_files $uri $uri/ =404;
            try_files $uri $uri/ /index.php?$query_string;
            }

 # versión 8.4 de PHP
 location ~ .php$ {
                  include snippets/fastcgi-php.conf;
                  fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
    }
}

 

3. Instalación de Composer y node.js

 How to Install Composer in Ubuntu 22.04 LTS

curl -sS https://getcomposer.org/installer -o composer-setup.php
sudo php composer-setup.php --install-dir=/usr/local/bin --filename=composer
composer -V

Node Installation instructions

#Using Ubuntu
curl -fsSL https://deb.nodesource.com/setup_23.x | sudo -E bash - && sudo apt-get install -y nodejs
node -v
npm -v

error: trying to overwrite ‘/usr/include/node/common.gypi’
https://github.com/nodesource/distributions/issues/1691

sudo apt purge libnode-dev

sudo apt-get install -y nodejs

node -v

npm -v

 

4. Instalación de Laravel

(Eliminar la carpeta notesApp, si ya estaba creada)

cd ~
# borrar la carpeta notesApp, si ya existe
rm -r notesApp

Do not run Composer as root/super user! See https://getcomposer.org/root for details

Crear el proyecto

composer create-project laravel/laravel notesApp

cd notesApp

composer require laravel/jetstream

php artisan jetstream:install livewire

¿Qué es Laravel Jetstream y cómo empezar?

 

Otra forma: Usando el instalador de Laravel

Installation Via Composer

composer global require laravel/installer

laravel new notesApp

Error al usar el instalador de Laravel

 

Error en el droplet con 1 Gb de RAM (son convenientes 2 Gb de RAM, como mínimo):Redimensionar el droplet con 2 Gb de memoria RAM (cuando se termine la instalación se puede volver a redimensionar a 1 Gb):

5. Base de datos

Crear la base de datos notesDB y el usuario user_notesDB con la password Malaga2425*

Conectarse al servidor de bases de datos

sudo mysql

Ejecutar estos comandos:

CREATE DATABASE notesDB;
CREATE USER 'user_notesDB'@'localhost' IDENTIFIED BY 'Malaga2425*';
GRANT ALL PRIVILEGES ON notesDB.* TO 'user_notesDB'@'localhost';
FLUSH PRIVILEGES;
exit

Modificar el fichero .env:

nano .env

Información de conexión a la base de datos:

# DB_CONNECTION=sqlite
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=notesDB
DB_USERNAME=user_notesDB
DB_PASSWORD=Malaga2425*
DB_COLLATION=utf8mb4_unicode_ci

Instalar phpMyAdmin en el VPS (opcional)

ssh usuario@alumno.me

sudo apt install phpmyadmin
sudo apt install php8.4-mbstring
sudo ln -s  /usr/share/phpmyadmin /var/www/html/phpmyadmin
sudo nginx -t
sudo systemctl restart nginx
sudo systemctl status nginx

exit

 

6. Prueba

(php artisan migrate)

Cambiar el propietario de la carpeta storage y bootstrap/cache:

cd ~/notesApp
sudo chown -R www-data:www-data storage
sudo chown -R www-data:www-data bootstrap/cache

Permisos en bootstrap/cache y storage/logs/laravel.log:

sudo chmod 777 bootstrap/cache
sudo chmod 777 storage/logs/laravel.log

Ejecutar

npm install
npm run build

Comprobación:

laravel.alumno.me

 

7. Montar en el equipo local el sistema de ficheros remoto del VPS

Uso de SSH FS en Windows/Linux

Nota: Estos comandos se ejecutan en el equipo local, no en el VPS

mkdir servidor

sudo apt install sshfs

sshfs usuario@alumno.me:/home/usuario/notesApp/ servidor -p 22

Abrir la carpeta con el IDE Visual Studio Code

cd servidor

code .

Para desmontarlo:

fusermount -u servidor

8. Instalar extensiones en Visual Studio Code

Extensión Database Client

Configurar la extensión en las pestañas main y ssh

Usar sshfs en Visual Studio Code:

Instalar la extensión SSH FS

Configurarla con el host, ruta a la carpeta remota y usuario

Instalar la extensión PHP Intelephense

Instalar la extensión GitHub Copilot y GitHub Copilot Chat

 

9. MVC en Laravel

10. Creación de la aplicación CRUD

CRUD To-do Application in Laravel

 

Modelo:

con la opción -m para crear la migración

php artisan make:model Note -m

Relación uno a muchos

app/Models/Note.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Note extends Model
{
    use HasFactory;

    protected $fillable = ['title', 'description'];

    public function user():BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

app/Models/User.php

<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Sanctum\HasApiTokens;

use App\Models\Note;
use Illuminate\Database\Eloquent\Relations\HasMany;

class User extends Authenticatable
{
    use HasApiTokens;

    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasFactory;
    use HasProfilePhoto;
    use Notifiable;
    use TwoFactorAuthenticatable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'password',
        'remember_token',
        'two_factor_recovery_codes',
        'two_factor_secret',
    ];

    /**
     * The accessors to append to the model's array form.
     *
     * @var array<int, string>
     */
    protected $appends = [
        'profile_photo_url',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function notes(): HasMany    
    {
        return $this->hasMany(Note::class);
    }
}

Migraciones:

app/database/migrations/2025_05_07_084758_create_notes_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('notes', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('description');
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('notes');
    }
};

Realizar la migración:

php artisan migrate
o
php artisan migrate:refresh

Migrar una sola tabla:

php artisan migrate --path=/database/migrations/2025_05_07_084758_create_tasks_table.php

Rutas

routes/web.php

<?php

use Illuminate\Support\Facades\Route;

use App\Http\Controllers\NoteController;

Route::get('/', function () {
    return view('welcome');
});

Route::middleware([
    'auth:sanctum',
    config('jetstream.auth_session'),
    'verified',
])->group(function () {
    //Route::get('/dashboard', function () {
    //    return view('dashboard');
    //})->name('dashboard');
    Route::get('/dashboard',[NoteController::class, 'index'])->name('dashboard');
    Route::get('/note',[NoteController::class, 'add']);
    Route::post('/note',[NoteController::class, 'create']);
    
    Route::get('/note/{note}', [NoteController::class, 'edit']);
    Route::put('/note/{note}', [NoteController::class, 'update']);
    Route::delete('/note/{note}', [NoteController::class, 'destroy']);
});

Controlador

php artisan make:controller NoteController

app/Http/Controllers/NoteController.php

<?php

namespace App\Http\Controllers;

use App\Models\Note;
use Illuminate\Http\Request;

class NoteController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        $notes = auth()->user()->notes();
        // return view('dashboard', compact('notes'));
        return view('dashboard')->with('notes', $notes);
    }

    /**
     * Show the form for creating a new resource.
     */
    public function create(Request $request)
    {
        //$this->validate($request, [
        //    'title' => 'required',
        //    'description' => 'required'
        //]);
        // Validar los datos del formulario
        $request->validate([
            'title' => 'required|max:255',
            'description' => 'required'
        ]);
        $note = new Note();
        $note->title = $request->title;
        $note->description = $request->description;
        $note->user_id = auth()->user()->id;
        $note->save();
        return redirect('/dashboard'); 
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(Request $request)
    {
        //
    }

    /**
     * Add a newly created resource in storage.
     */
    public function add()
    {
        return view('add');
    }

    /**
     * Display the specified resource.
     */
    public function show(Note $note)
    {
        //
    }

    /**
     * Show the form for editing the specified resource.
     */
    public function edit(Note $note)
    {
        if (auth()->user()->id == $note->user_id)
        {            
                return view('edit', compact('note'));
        }           
        else {
             return redirect('/dashboard');
         }                
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(Request $request, Note $note)
    {
        //$this->validate($request, [
        //    'title' => 'required',
        //    'description' => 'required'
        //]);
        $request->validate([
            'title' => 'required|max:255',
            'description' => 'required'
        ]);
        $note->title = $request->title;
        $note->description = $request->description;
        $note->save();
        return redirect('/dashboard'); 
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(Note $note)
    {
        $note->delete();
        return redirect('/dashboard');
        // return redirect('/dashboard')->with('success', 'Note deleted successfully');

    }
}

Vistas

Qué es TailwindCSS

Blade, Bootstrap y TailwindCSS

Se encuentran en resources/views

  1. dashboard.blade.php ( Dashboard muestra la lista de tareas)
  2. add.blade.php (Formulario para añadir una nueva tarea)
  3. edit.blade.php (Formulario para añadir una nueva tarea)
  4. welcome.blade.php (página inicial)

resources/views/dashboard.blade.php

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Dashboard') }}
        </h2>
    </x-slot>
   
    <div class="py-12 dark:bg-gray-800">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8 dark:bg-gray-800">
            <div class="bg-white dark:bg-gray-800 overflow-hidden shadow-xl sm:rounded-lg p-5">
                <table class="w-full text-md rounded mb-4 dark:bg-gray-800">
                    <thead>
                    <tr>
                        <th>
                            <div class="flex-auto text-left text-2xl mt-4 mb-4 dark:text-white">Notes List</div>
                        </th>
                        <th>
                            <div class="flex-auto text-right float-right mt-4 mb-4">
                                <a href="/note" class="bg-blue-500 dark:bg-cyan-700 hover:bg-gray-700 text-white font-bold mr-8 py-2 px-4 rounded">Add a new note</a>                        
                            </div>
                        </th>
                    </tr>
                    <tr class="border-b dark:text-white text-center">
                        <th class="text-center p-3 px-5">Title</th>
                        <th class="text-center p-3 px-5">Description</th>
                        <th class="text-right p-3 px-5">Actions</th>
                    </tr>
                    </thead>
                    <tbody>
                    @foreach(auth()->user()->notes as $note)
                        <tr class="border-b hover:bg-orange-100 dark:text-white text-center">
                            <td class="p-3 px-5">
                                {{$note->title}}
                            </td>    
                            <td class="p-3 px-5">
                                {{$note->description}}
                            </td>
                            <td class="p-3 px-5">                                  
                                <td>
                                    <a href="/note/{{$note->id}}" class="btn btn-primary">Edit</a>
                                </td>
                                <td>
                                    <form action="/note/{{$note->id}}" method="post">
                                    @csrf 
                                    @method('DELETE')
                                    <button type="submit" class="btn btn-danger">Delete</button>
                                    </form>
                                </td>
                            </td>
                        </tr>
                    @endforeach
                    </tbody>
                </table>
                
            </div>
        </div>
    </div>
</x-app-layout>

resources/views/add.blade.php

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Add Note') }}
        </h2>
    </x-slot>
    
    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white dark:bg-gray-800 overflow-hidden shadow-xl sm:rounded-lg p-5">
            
                <form method="POST" action="/note">
                
                    @csrf
                    <div class="form-group">
                        <textarea name="title" class="bg-gray-100 dark:text-white dark:bg-gray-800 rounded border border-gray-400 leading-normal resize-none w-full h-20 py-2 px-3 font-medium placeholder-gray-700 focus:outline-none focus:bg-white"  placeholder='Enter the title'></textarea>  
                        @if ($errors->has('title'))
                            <span class="text-danger dark:text-white dark:bg-gray-500">{{ $errors->first('title') }}</span>
                        @endif
                    </div>
    
                    <div class="form-group">
                        <textarea name="description" class="bg-gray-100 dark:text-white dark:bg-gray-800 rounded border border-gray-400 leading-normal resize-none w-full h-20 py-2 px-3 font-medium placeholder-gray-700 focus:outline-none focus:bg-white"  placeholder='Enter the description'></textarea>  
                        @if ($errors->has('description'))
                            <span class="text-danger dark:text-white dark:bg-gray-500">{{ $errors->first('description') }}</span>
                        @endif
                    </div>
    
                    <div class="form-group">
                        <button type="submit" class="btn btn-primary">Add note</button>
                    </div>
                </form>
            </div>
        </div>
    </div>
</x-app-layout>

resources/views/edit.blade.php

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Edit Note') }}
        </h2>
    </x-slot>
    
    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white dark:bg-gray-800 overflow-hidden shadow-xl sm:rounded-lg p-5">
            
                <form method="POST" action="/note/{{ $note->id }}">
                
                @csrf
                @method('PUT')

                <div class="form-group">
                        <textarea name="title" class="bg-gray-100 dark:text-white dark:bg-gray-800 rounded border border-gray-400 leading-normal resize-none w-full h-20 py-2 px-3 font-medium placeholder-gray-700 focus:outline-none focus:bg-white">{{$note->title }}</textarea>    
                        @if ($errors->has('title'))
                            <span class="text-danger dark:text-white dark:bg-red-500">{{ $errors->first('title') }}</span>
                        @endif
                    </div>
    
                    <div class="form-group">
                        <textarea name="description" class="bg-gray-100 dark:text-white dark:bg-gray-800 rounded border border-gray-400 leading-normal resize-none w-full h-20 py-2 px-3 font-medium placeholder-gray-700 focus:outline-none focus:bg-white">{{$note->description }}</textarea>    
                        @if ($errors->has('description'))
                            <span class="text-danger dark:text-white dark:bg-red-500">{{ $errors->first('description') }}</span>
                        @endif
                    </div>
    
                    <div class="form-group">
                        <button type="submit" name="update" class="btn btn-primary">Update note</button>
                    </div>

                </form>
            </div>
        </div>
    </div>
</x-app-layout>

public/css/styles.css (obtenido de welcome.blade.php)

public/css/styles.css

Contenido del fichero styles.css

fichero public/css/colors.css

.bg-blue-500
{background-color:#66bbe3}

.bg-blue-700
{background-color:#1f93c9}

.bg-red-500
{background-color:#e37966}

.bg-red-700
{background-color:#b73f24}

Añadir Bootstrap

<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">

resources/views/layouts/app.blade.css

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="csrf-token" content="{{ csrf_token() }}">

        <title>{{ config('app.name', 'Laravel') }}</title>

        <!-- Fonts -->
        <link rel="preconnect" href="https://fonts.bunny.net">
        <link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />

        <link rel="stylesheet" href="css/styles.css">
        <link rel="stylesheet" href="css/colors.css">

        <!-- Bootstrap -->
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">


        <!-- Scripts -->
        @vite(['resources/css/app.css', 'resources/js/app.js'])

        <!-- Styles -->
        @livewireStyles
    </head>
    <body class="font-sans antialiased">
        <x-banner />

        <div class="min-h-screen bg-gray-100">
            @livewire('navigation-menu')

            <!-- Page Heading -->
            @if (isset($header))
                <header class="bg-white shadow">
                    <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
                        {{ $header }}
                    </div>
                </header>
            @endif

            <!-- Page Content -->
            <main>
                {{ $slot }}
            </main>
        </div>

        @stack('modals')

        @livewireScripts
    </body>
</html>

resources/views/welcome.blade.php

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel</title>

        <!-- Fonts -->
        <link rel="preconnect" href="https://fonts.bunny.net">
        <link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />

        <!-- Bootstrap -->
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
        
        <!-- Styles -->
        <!-- normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css  -->
        

        <!-- Styles / Scripts -->
        @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot')))
            @vite(['resources/css/app.css', 'resources/js/app.js'])
        @else
            <link rel="stylesheet" href="css/styles.css">
        @endif
    </head>
    <body class="bg-[#FDFDFC] dark:bg-[#0a0a0a] text-[#1b1b18] flex p-6 lg:p-8 items-center lg:justify-center min-h-screen flex-col">
        <header class="w-full lg:max-w-4xl max-w-[335px] text-sm mb-6 not-has-[nav]:hidden">
            @if (Route::has('login'))
                <nav class="flex items-center justify-end gap-4">
                    @auth
                        <a
                            href="{{ url('/dashboard') }}"
                            class="inline-block px-5 py-1.5 dark:text-[#EDEDEC] border-[#19140035] hover:border-[#1915014a] border text-[#1b1b18] dark:border-[#3E3E3A] dark:hover:border-[#62605b] rounded-sm text-sm leading-normal"
                        >
                            Dashboard
                        </a>
                    @else
                        <a
                            href="{{ route('login') }}"
                            class="inline-block px-5 py-1.5 dark:text-[#EDEDEC] text-[#1b1b18] border border-transparent hover:border-[#19140035] dark:hover:border-[#3E3E3A] rounded-sm text-sm leading-normal"
                        >
                            Log in
                        </a>

                        @if (Route::has('register'))
                            <a
                                href="{{ route('register') }}"
                                class="inline-block px-5 py-1.5 dark:text-[#EDEDEC] border-[#19140035] hover:border-[#1915014a] border text-[#1b1b18] dark:border-[#3E3E3A] dark:hover:border-[#62605b] rounded-sm text-sm leading-normal">
                                Register
                            </a>
                        @endif
                    @endauth
                </nav>
            @endif
        </header>

        <div class="flex items-center justify-center w-full transition-opacity opacity-100 duration-750 lg:grow starting:opacity-0">
            <main class="flex max-w-[335px] w-full flex-col-reverse lg:max-w-4xl lg:flex-row">

            <div class="max-w-4xl mx-auto sm:px-6 lg:px-4">
                    
                <div class="flex justify-center text-primary">
                    <h1>Notes</h1>                    
                </div>
                <div class="flex justify-center text-success">
                    <h3>Manage your notes online</h3>
                </div>
                <br />
                <div class="flex justify-center text-secondary">
                    <h4>info@alumno.me</h4>
                </div>
                <br />
                <br />
                <div class="flex justify-center">                   
                    <div class="ml-4 text-center text-sm text-gray-500">
                        Laravel v{{ Illuminate\Foundation\Application::VERSION }} (PHP v{{ PHP_VERSION }})
                    </div>
                </div>

            </div>

        </div>


        @if (Route::has('login'))
            <div class="h-14.5 hidden lg:block"></div>
        @endif
    </body>
</html>

11. Limpiar la caché de Laravel

php artisan cache:clear
php artisan config:clear
php artisan route:clear
php artisan view:clear

 

12. Prueba

laravel.alumno.me

13. Subir los ficheros a un repositorio en GitHub

ssh usuario@alumno.me

Instalar y configurar Git en el VPS

sudo apt install git

git config --global user.name "John Doe"
git config --global user.email johndoe@example.com

Crear el repositorio en GitHub

Subir los ficheros al repositorio (sustituyendo el TOKEN por el valor que tenga cada uno)

cd ~/notesApp

echo "# Notes App with Laravel" >> README.md
git init
git branch -M main
git remote add origin https://TOKEN@github.com/paco-portada/notesapplaravel2025.git
 
git add .
git commit -m "first commit"
git push -u origin main

Ayuda: Crear un repositorio y subir nuestro proyecto local a Github con VSCode

 

Más información:

Laravel 12 CRUD

Laravel 11 CRUD:

Código del proyecto en Github

Código del proyecto en Github

Deja una respuesta