Vai al contenuto

Symfony 6, MySql e React. Creare una CRUD Web App in modo semplice ed efficace

Uno dei problemi più diffusi e comuni nello sviluppo di software è quello della creazione di applicazioni che permettano di gestire e manipolare dei dati persistenti. Solitamente applicazioni di questo tipo, sono chiamate CRUD (che significa semplicemente create, read, update, e delete). In questo articolo costruiremo una semplice app dinamica che permetta di effettuare le operazioni CRUD, utilizzando un’interfaccia moderna React. Il progetto in questione sarà quindi un mix di tecniche già mostrate in alcuni articoli precedenti che sono disponibili cliccando i link seguenti:

Symfony  è un framework PHP utilizzato per sviluppare applicazioni web, API, microservizi e servizi web. Rappresenta uno dei principali framework PHP per la creazione di siti web e applicazioni web. React  o anche chiamato React.js o Reactjs è una libreria JavaScript gratuita e open source utilizzata per la creazione di interfacce utente (UI). È una delle librerie JavaScript più popolari per la creazione del front-end. React è creato da Facebook e gestito da Facebook.

Creiamo il progetto Symfony

Ricordiamoci di aver installato una versione di PHP pari o superiore a 8.0 e Composer, ed inoltre aver installato Node e NPM e YARN.

Selezioniamo una cartella in cui desideriamo installare Symfony, quindi eseguiamo questo comando sul Terminale (start, esegui, cmd) per l’installazione:

composer create-project symfony/website-skeleton symfony-6-react-crud

Composer si occuperà di installare tutte le librerie necessarie al corretto funzionamento. Alla domanda:

Do you want to include Docker configuration from recipes?

possiamo rispondere anche N. Non ci interessa una configurazione per Docker.

Attendiamo quindi il completamento dell’installazione che avverrà in qualche decina di secondi.

Impostiamo il database

Per configure il database che conterrà i nostri dati, apririamo il file  .env che si trova nella root dell’app e impostiamo le credenziali del database e il nome. Useremo MySQL in questo tutorial, quindi sarà necessario togliere il commento nella riga la variabile DATABASE_URL relativa a MySQL e aggiornare le sua configurazione. Assicuriamoci di aver commentato le altre variabili DATABASE_URL, altrimenti riceveremo un errore, specie se Symfony non dovesse trovare gli altri motori di database (ad esempio Postgres).

###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8&charset=utf8mb4"
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=14&charset=utf8"
###< doctrine/doctrine-bundle ###

Dopo aver configurato il database, eseguiamo questo comando per creare il database:

php bin/console doctrine:database:create

Creiamo le Entità e migriamole sul DB

Eseguiamo questo comando per creare una nuova Entità di nome Progetto con due campi di tipo testo chiamati nome e descrizione:

php bin/console make:entity
New property name (press <return> to stop adding fields):
 > nome
 Field type (enter ? to see all types) [string]:
 >
 Field length [255]:
 >
 Can this field be null in the database (nullable) (yes/no) [no]:
 >
 updated: src/Entity/Progetto.php
 Add another property? Enter the property name (or press <return> to stop adding fields):
 > descrizione
 Field type (enter ? to see all types) [string]:
 >
 Field length [255]:
 >
 Can this field be null in the database (nullable) (yes/no) [no]:
 >
 updated: src/Entity/Progetto.php
 Add another property? Enter the property name (or press <return> to stop adding fields):
 >
  Success!
 Next: When you're ready, create a migration with php bin/console make:migration

A questo punto, come già ci suggerisce Symfony, possiamo generare il file di migrazione e migrare l’entità Progetto sul nostro DB attraverso i comandi:

php bin/console make:migration
php bin/console doctrine:migrations:migrate

Creiamo i Controller

Un Controller è il responsabile della ricezione delle Request e della restituzione della Response. Creiamo il Controller responsabile della logica API. Tramite riga di comando eseguiamo il seguente comando:

php bin/console make:controller ProgettoController

Quindi, apriamo il file generato in src/Controller/ProjectController.php e aggiungiamo queste righe di codice:

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Request;
use App\Entity\Progetto;

#[Route('/api', name: 'api_')]
class ProgettoController extends AbstractController
{
    #[Route('/progetto', name: 'app_progetto', methods: array('GET'))]
    public function index(ManagerRegistry $doctrine, Request $request): Response
    {
        $progetti = $doctrine
            ->getRepository(Progetto::class)
            ->findAll();
   
        $data = [];
   
        foreach ($progetti as $project) {
           $data[] = [
               'id' => $project->getId(),
               'nome' => $project->getNome(),
               'descrizione' => $project->getDescrizione(),
           ];
        }
   
   
        return $this->json($data);
    }

    #[Route('/progetto', name: 'progetto_new', methods: array('POST'))]
    public function new(ManagerRegistry $doctrine, Request $request): Response
    {
        $entityManager = $doctrine->getManager();
   
        $project = new Progetto();
        $project->setNome($request->request->get('nome'));
        $project->setDescrizione($request->request->get('descrizione'));
   
        $entityManager->persist($project);
        $entityManager->flush();
   
        return $this->json('Nuovo progetto creato con id ' . $project->getId());
    }
   
    #[Route('/progetto/{id}', name: 'progetto_show', methods: array('GET'))]
    public function show(ManagerRegistry $doctrine, int $id): Response
    {
        $project = $doctrine->getRepository(Progetto::class)->find($id);
   
        if (!$project) {
            return $this->json('Nessun progetto trovato con id' . $id, 404);
        }
   
        $data =  [
            'id' => $project->getId(),
            'nome' => $project->getNome(),
            'descrizione' => $project->getDescrizione(),
        ];
           
        return $this->json($data);
    }
   
    #[Route('/progetto/{id}', name: 'progetto_edit', methods: array('PUT', 'PATCH'))]
    public function edit(ManagerRegistry $doctrine, Request $request, int $id): Response
    {
        $entityManager = $doctrine->getManager();
        $project = $entityManager->getRepository(Progetto::class)->find($id);
   
        if (!$project) {
            return $this->json('Nessun progetto trovato con id' . $id, 404);
        }
         
        $content = json_decode($request->getContent());
        $project->setNome($content->nome);
        $project->setDescrizione($content->descrizione);
        $entityManager->flush();
   
        $data =  [
            'id' => $project->getId(),
            'nome' => $project->getNome(),
            'descrizione' => $project->getDescrizione(),
        ];
           
        return $this->json($data);
    }
   
    #[Route('/progetto/{id}', name: 'progetto_delete', methods: array('DELETE'))]
    public function delete(ManagerRegistry $doctrine, int $id): Response
    {
        $entityManager = $doctrine->getManager();
        $project = $entityManager->getRepository(Progetto::class)->find($id);
   
        if (!$project) {
            return $this->json('Nessun progetto trovato con id' . $id, 404);
        }
   
        $entityManager->remove($project);
        $entityManager->flush();
   
        return $this->json('Cancellato progetto con id ' . $id);
    }
}

Adesso creiamo un altro controller che che caricherà l’app di React, con il seguente comando:

php bin/console make:controller SpaController

Anche in questo caso, bisognerà modificare il file creato in  /src/Controller/SpaController.php con queste righe di codice:

<?php
  
namespace App\Controller;
  
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class SpaController extends AbstractController
{
    #[Route('/{reactRouting}', name: 'app_spa', requirements: array("reactRouting"=>"^(?!api).+"), defaults: array("reactRouting"=>null))]
    public function index(): Response
    {
        return $this->render('spa/index.html.twig', [
            'controller_name' => 'SpaController',
        ]);
    }
}

Aggiorniamo i template

Aggiorniamo ora i template di visualizzazione. Innanzitutto, modifichiamo la base /templates/base.html.twig incollando il codice qui sotto, andando a sostituire tutto ciò che c’era prima:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>{% block title %}Symfony React SPA!{% endblock %}</title>
    {% block stylesheets %}
        {{ encore_entry_link_tags('app') }}
    {% endblock %}
     <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
</head>
<body>
{% block body %}{% endblock %}
{% block javascripts %}
    {{ encore_entry_script_tags('app') }}
{% endblock %}
</body>
</html>

Quindi, aggiorniamo pure il template che è stato generato quando abbiamo creato il controller della Spa in /templates/spa/index.html.twig:

{% extends 'base.html.twig' %}
      
{% block body %}      
    <div id="app"></div>      
{% endblock %}

Ora che abbiamo impostato il back-end, procediamo con il front-end.

Installiamo Encore e le dipendenze React

Installiamo il Symfony Webpack Encore Bundle con questi comandi per installare le dipendenze PHP e JavaScript:

composer require symfony/webpack-encore-bundle
yarn install

Quindi installiamo le dipendenze per la nostra React:

yarn add @babel/preset-react --dev
yarn add react-router-dom
yarn add --dev react react-dom prop-types axios
yarn add @babel/plugin-proposal-class-properties @babel/plugin-transform-runtime

E una libreria per dare un tocco di classe alle finestre di dialogo:

npm install sweetalert2

Aggiorniamo adesso la configurazione di  webpack.config.js nella root del progetto. Si notino i commenti aggiungi al file per capire quali modifiche eseguire al file:

const Encore = require('@symfony/webpack-encore');

// Manually configure the runtime environment if not already configured yet by the "encore" command.
// It's useful when you use tools that rely on webpack.config.js file.
if (!Encore.isRuntimeEnvironmentConfigured()) {
    Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}

Encore
    // directory where compiled assets will be stored
    .setOutputPath('public/build/')
    // public path used by the web server to access the output path
    .setPublicPath('/build')
    .enableReactPreset() // RIGA DA AGGIUNGERE
    // only needed for CDN's or sub-directory deploy
    //.setManifestKeyPrefix('build/')

    /*
     * ENTRY CONFIG
     *
     * Each entry will result in one JavaScript file (e.g. app.js)
     * and one CSS file (e.g. app.css) if your JavaScript imports CSS.
     */
    .addEntry('app', './assets/app.js')

    // enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js)
    .enableStimulusBridge('./assets/controllers.json')

    // When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
    .splitEntryChunks()

    // will require an extra script tag for runtime.js
    // but, you probably want this, unless you're building a single-page app
    .enableSingleRuntimeChunk()

    /*
     * FEATURE CONFIG
     *
     * Enable & configure other features below. For a full
     * list of features, see:
     * https://symfony.com/doc/current/frontend.html#adding-more-features
     */
    .cleanupOutputBeforeBuild()
    .enableBuildNotifications()
    .enableSourceMaps(!Encore.isProduction())
    // enables hashed filenames (e.g. app.abc123.css)
    .enableVersioning(Encore.isProduction())

    // configure Babel // RIGA DA DECOMMENTARE E MODIFICARE RISPETTO A QUELLA DI DEFAULT
    .configureBabel((config) => {
        config.plugins.push('@babel/plugin-proposal-class-properties');
    })

    // enables and configure @babel/preset-env polyfills
    .configureBabelPresetEnv((config) => {
        config.useBuiltIns = 'usage';
        config.corejs = '3.23';
    })

    // enables Sass/SCSS support
    //.enableSassLoader()

    // uncomment if you use TypeScript
    //.enableTypeScriptLoader()

    // uncomment if you use React
    //.enableReactPreset()

    // uncomment to get integrity="..." attributes on your script & link tags
    // requires WebpackEncoreBundle 1.4 or higher
    //.enableIntegrityHashes(Encore.isProduction())

    // uncomment if you're having problems with a jQuery plugin
    //.autoProvidejQuery()
;

module.exports = Encore.getWebpackConfig();

Creiamo i file React

Iniziamo ora a creare i nostri file di React. Ma prima eseguiamo questo comando per compilare i file di React in tempo reale e guardare le modifiche ai file JavaScript:

yarn encore dev --watch

Creeremo questi file all’interno  della cartella /assets. I file (semplici file di testo) possono essere già creati vuoti e man mano li andremo a riempire. La struttura delle cartelle e dei files dovrà essere uguale a questa:

Innanzitutto creiamo il file che gestisce il routing, Main.js:

import React from 'react';
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import ProgettoElenco from "./pages/ProgettoElenco"
import ProgettoNuovo from "./pages/ProgettoNuovo"
import ProgettoModifica from "./pages/ProgettoModifica"
import ProgettoMostra from "./pages/ProgettoMostra"
    
function Main() {
    return (
        <Router>
            <Routes>
                <Route exact path="/"  element={<ProgettoElenco/>} />
                <Route path="/nuovo"  element={<ProgettoNuovo/>} />
                <Route path="/modifica/:id"  element={<ProgettoModifica/>} />
                <Route path="/mostra/:id"  element={<ProgettoMostra/>} />
            </Routes>
        </Router>
    );
}
    
export default Main;
    
if (document.getElementById('app')) {
    const rootElement = document.getElementById("app");
    const root = createRoot(rootElement);
  
    root.render(
        <StrictMode>
            <Main />
        </StrictMode>
    );
}

quindi aggiorniamo il file app.js, aggiungendo alla fine questa riga di codice:

require('./Main');

Man mano che effettueremo le modifiche, il Webpack Encore ci avviserà della corretta compilazione dei file JS. Proseguiamo dunque a impostare gli altri file, partendo dal file creato in /components e chiamato Layout.js

import React from 'react';
   
const Layout =({children}) =>{
    return(
        <div className="container">
            {children}
        </div>
    )
}
    
export default Layout;

Adesso è il turno dei file posizionati nella cartella /pages, che rappresentano ognuna le singole schermate da far visualizzare nel browser agli utenti:

  • ProgettoNuovo.js
  • ProgettoModifica.js
  • ProgettoElenco.js
  • ProgettoMostra.js

/assets/pages/ProgettoNuovo.js

import React, {useState} from 'react';
import { Link } from "react-router-dom";
import Layout from "../components/Layout"
import Swal from 'sweetalert2'
import axios from 'axios';
  
function ProjectNuovo() {
    const [nome, setNome] = useState('');
    const [descrizione, setDescrizione] = useState('')
    const [isSaving, setIsSaving] = useState(false)
  
    const handleSave = () => {
        setIsSaving(true);
        let formData = new FormData()
        formData.append("nome", nome)
        formData.append("descrizione", descrizione)
        axios.post('/api/progetto', formData)
          .then(function (response) {
            Swal.fire({
                icon: 'success',
                title: 'Progetto salvato!',
                showConfirmButton: false,
                timer: 1500
            })
            setIsSaving(false);
            setNome('')
            setDescrizione('')
          })
          .catch(function (error) {
            Swal.fire({
                icon: 'error',
                title: 'Errore!',
                showConfirmButton: false,
                timer: 1500
            })
            setIsSaving(false)
          });
    }
  
    return (
        <Layout>
            <div className="container">
                <h2 className="text-center mt-5 mb-3">Crea Nuovo Progetto</h2>
                <div className="card">
                    <div className="card-header">
                        <Link 
                            className="btn btn-outline-info float-right"
                            to="/">Guarda tutti i progetti
                        </Link>
                    </div>
                    <div className="card-body">
                        <form>
                            <div className="form-group">
                                <label htmlFor="nome">Nnme</label>
                                <input 
                                    onChange={(event)=>{setNome(event.target.value)}}
                                    value={nome}
                                    type="text"
                                    className="form-control"
                                    id="nome"
                                    name="nome"/>
                            </div>
                            <div className="form-group">
                                <label htmlFor="descrizione">Descrizione</label>
                                <textarea 
                                    value={descrizione}
                                    onChange={(event)=>{setDescrizione(event.target.value)}}
                                    className="form-control"
                                    id="descrizione"
                                    rows="3"
                                    name="descrizione"></textarea>
                            </div>
                            <button 
                                disabled={isSaving}
                                onClick={handleSave} 
                                type="button"
                                className="btn btn-outline-primary mt-3">
                                Save Project
                            </button>
                        </form>
                    </div>
                </div>
            </div>
        </Layout>
    );
}
  
export default ProjectNuovo;

/assets/pages/ProgettoModifica.js

import React, { useState, useEffect } from 'react';
import { Link, useParams } from "react-router-dom";
import Layout from "../components/Layout"
import Swal from 'sweetalert2'
import axios from 'axios';
  
function ProgettoModifica() {
    const [id, setId] = useState(useParams().id)
    const [nome, setNome] = useState('');
    const [descrizione, setDescrizione] = useState('')
    const [isSaving, setIsSaving] = useState(false)
  
      
    useEffect(() => {
        axios.get(`/api/progetto/${id}`)
        .then(function (response) {
            let project = response.data
            setNome(project.nome);
            setDescrizione(project.descrizione);
        })
        .catch(function (error) {
            Swal.fire({
                icon: 'error',
                title: 'Errore!',
                showConfirmButton: false,
                timer: 1500
            })
        })
          
    }, [])
  
  
    const handleSave = () => {
        setIsSaving(true);
        axios.patch(`/api/progetto/${id}`, {
            nome: nome,
            descrizione: descrizione
        })
        .then(function (response) {
            Swal.fire({
                icon: 'success',
                title: 'Progetto aggiornato!',
                showConfirmButton: false,
                timer: 1500
            })
            setIsSaving(false);
        })
        .catch(function (error) {
            Swal.fire({
                icon: 'error',
                title: 'Errore!',
                showConfirmButton: false,
                timer: 1500
            })
            setIsSaving(false)
        });
    }
  
  
    return (
        <Layout>
            <div className="container">
                <h2 className="text-center mt-5 mb-3">Modifica progetto</h2>
                <div className="card">
                    <div className="card-header">
                        <Link 
                            className="btn btn-outline-info float-right"
                            to="/">Guarda tutti i progetti
                        </Link>
                    </div>
                    <div className="card-body">
                        <form>
                            <div className="form-group">
                                <label htmlFor="nome">Nome</label>
                                <input 
                                    onChange={(event)=>{setNome(event.target.value)}}
                                    value={nome}
                                    type="text"
                                    className="form-control"
                                    id="nome"
                                    name="nome"/>
                            </div>
                            <div className="form-group">
                                <label htmlFor="descrizione">Description</label>
                                <textarea 
                                    value={descrizione}
                                    onChange={(event)=>{setDescrizione(event.target.value)}}
                                    className="form-control"
                                    id="descrizione"
                                    rows="3"
                                    name="descrizione"></textarea>
                            </div>
                            <button disabled={isSaving}
                                onClick={handleSave} 
                                type="button"
                                className="btn btn-outline-success mt-3">
                                Aggiorna progetto
                            </button>
                        </form>
                    </div>
                </div>
            </div>
        </Layout>
    );
}
  
export default ProgettoModifica;

/assets/pages/ProgettoElenco.js

import React,{ useState, useEffect} from 'react';
import { Link } from "react-router-dom";
import Layout from "../components/Layout"
import Swal from 'sweetalert2'
import axios from 'axios';
 
function ProgettoElenco() {
    const  [elencoProgetti, setProgettoElenco] = useState([])
  
    useEffect(() => {
        fetchProgettoElenco()
    }, [])
  
    const fetchProgettoElenco = () => {
        axios.get('/api/progetto')
        .then(function (response) {
            setProgettoElenco(response.data);
        })
        .catch(function (error) {
          console.log(error);
        })
    }
  
    const handleDelete = (id) => {
        Swal.fire({
            title: 'Sei sicuro?',
            text: "E' un'operazione irreversibile!",
            icon: 'warning',
            showCancelButton: true,
            confirmButtonColor: '#3085d6',
            cancelButtonColor: '#d33',
            confirmButtonText: 'Si, cancella!'
          }).then((result) => {
            if (result.isConfirmed) {
                axios.delete(`/api/progetto/${id}`)
                .then(function (response) {
                    Swal.fire({
                        icon: 'success',
                        title: 'Progetto cancellato!',
                        showConfirmButton: false,
                        timer: 1500
                    })
                    fetchProgettoElenco()
                })
                .catch(function (error) {
                    Swal.fire({
                        icon: 'error',
                        title: 'Errore!',
                        showConfirmButton: false,
                        timer: 1500
                    })
                });
            }
          })
    }
  
    return (
        <Layout>
           <div className="container">
            <h2 className="text-center mt-5 mb-3">Manager Progetti</h2>
                <div className="card">
                    <div className="card-header">
                        <Link 
                            className="btn btn-outline-primary"
                            to="/nuovo">Crea nuovo Progetto
                        </Link>
                    </div>
                    <div className="card-body">
              
                        <table className="table table-bordered">
                            <thead>
                                <tr>
                                    <th>Nome</th>
                                    <th>Descrizione</th>
                                    <th width="240px">Action</th>
                                </tr>
                            </thead>
                            <tbody>
                                {elencoProgetti.map((project, key)=>{
                                    return (
                                        <tr key={key}>
                                            <td>{project.nome}</td>
                                            <td>{project.descrizione}</td>
                                            <td>
                                                <Link to={`/mostra/${project.id}`}
                                                    className="btn btn-outline-info mx-1">
                                                    Mostra
                                                </Link>
                                                <Link to={`/modifica/${project.id}`}
                                                    className="btn btn-outline-success mx-1">
                                                    Modifica
                                                </Link>
                                                <button onClick={()=>handleDelete(project.id)}
                                                    className="btn btn-outline-danger mx-1">
                                                    Cancella
                                                </button>
                                            </td>
                                        </tr>
                                    )
                                })}
                            </tbody>
                        </table>
                    </div>
                </div>
            </div>
        </Layout>
    );
}
  
export default ProgettoElenco;

/assets/pages/ProgettoMostra.js

import React, {useState, useEffect} from 'react';
import { Link, useParams } from "react-router-dom";
import Layout from "../components/Layout"
import axios from 'axios';
  
function ProgettoMostra() {
    const [id, setId] = useState(useParams().id)
    const [project, setProgetto] = useState({nome:'', descrizione:''})
    useEffect(() => {
        axios.get(`/api/progetto/${id}`)
        .then(function (response) {
            setProgetto(response.data)
        })
        .catch(function (error) {
          console.log(error);
        })
    }, [])
  
    return (
        <Layout>
           <div className="container">
            <h2 className="text-center mt-5 mb-3">Mostra Progetto</h2>
                <div className="card">
                    <div className="card-header">
                        <Link 
                            className="btn btn-outline-info float-right"
                            to="/"> View All Projects
                        </Link>
                    </div>
                    <div className="card-body">
                        <b className="text-muted">Nome:</b>
                        <p>{project.nome}</p>
                        <b className="text-muted">Descrizione:</b>
                        <p>{project.descrizione}</p>
                    </div>
                </div>
            </div>
        </Layout>
    );
}
  
export default ProgettoMostra;

Eseguiamo l’applicazione

Dopo aver completato i passaggi precedenti, possiamo eseguire l’applicazione con il comando seguente:

symfony server:start

Se tutto è stato impostato correttamente, l’app sarà disponibile al seguente URL: http://localhost:8000