×
Inicio Aleatorio

Primeros pasos con Symfony 5

Sección: Symfony

Creado: 31-01-21 (Actualizado: 28-04-22)

Iniciamos un nuevo proyecto

Cuando iniciamos un proyecto en symfony 5 tenemos varias opcions. Las 2 más comunes son el proyecto mínimo y el completo.

Básico

sf new projectname [--version=5.0]
symfony composer req profiler --dev
symfony composer req logger
symfony composer req debug --dev
symfony composer req annotations
symfony composer req maker --dev
symfony composer req orm

Completo

sf new projectname --full [--version=5.0]
symfony composer req string

Permisos

sudo setfacl -dR -m u:"$HTTPDUSER":rwX -m u:$(whoami):rwX var
sudo setfacl -R -m u:"$HTTPDUSER":rwX -m u:$(whoami):rwX var

Configuramos

# config/packages/framework.yaml
framework:
    session:
        handler_id: '%env(REDIS_URL)%'
    ide: vscode://file/%%f:%%l&/var/www/html/>/home/manuel/projects/docker/nginx/sf/
# config/packages/doctrine.yaml
doctrine:
    dbal:
        dbname: '%env(DATABASE_NAME)%'
        host: '%env(DATABASE_HOST)%'
        #port: '%env(DATABASE_PORT)%'
        user: '%env(DATABASE_USER)%'
        password: '%env(DATABASE_PASSWORD)%'
        #driver: '%env(DATABASE_DRIVER)%'
        server_version: '%env(DATABASE_VERSION)%'
        # url: '%env(resolve:DATABASE_URL)%'
#.env
DATABASE_NAME='db_name'
DATABASE_HOST='mariadb'
DATABASE_USER='root'
DATABASE_VERSION='mariadb-10.5.1'
REDIS_URL=redis://localhost:6379?timeout=5
sf console secrets:set DATABASE_PASSWORD

Modelo

symfony console make:entity Conference
* city,string,255,no
* year,string,4,no
* isInternational,boolean,no
* slug,string,255,no

symfony console make:entity Comment
* author,string,255,no
* text,text,no
* email,string,255,no
* photoFilename,strine,255,yes
* createdAt,datetime,no
symfony console make:entity Conference
* comments,OneToMany,Comment,no,yes
# src/entity/Conference.php
<?php

namespace App\Entity;

use App\Repository\ConferenceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\String\Slugger\SluggerInterface;

/**
 * @ORM\Entity(repositoryClass=ConferenceRepository::class)
 * @UniqueEntity("slug")
 */
class Conference
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $city;

    /**
     * @ORM\Column(type="string", length=4)
     */
    private $year;

    /**
     * @ORM\Column(type="boolean")
     */
    private $isInternational;

    /**
     * @ORM\OneToMany(targetEntity=Comment::class, mappedBy="conference", orphanRemoval=true)
     */
    private $comments;

    /**
     * @ORM\Column(type="string", length=255, unique=true)
     */
    private $slug;

    public function __toString(): string
    {
        return $this->city . ' ' . $this->year;
    }

    public function __construct()
    {
        $this->comments = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function computeSlug(SluggerInterface $slugger)
    {
        if (!$this->slug || '-' === $this->slug) {
            $this->slug = (string) $slugger->slug((string) $this)->lower();
        }
    }

    public function getCity(): ?string
    {
        return $this->city;
    }

    public function setCity(string $city): self
    {
        $this->city = $city;

        return $this;
    }

    public function getYear(): ?string
    {
        return $this->year;
    }

    public function setYear(string $year): self
    {
        $this->year = $year;

        return $this;
    }

    public function getIsInternational(): ?bool
    {
        return $this->isInternational;
    }

    public function setIsInternational(bool $isInternational): self
    {
        $this->isInternational = $isInternational;

        return $this;
    }

    /**
     * @return Collection|Comment[]
     */
    public function getComments(): Collection
    {
        return $this->comments;
    }

    public function addComment(Comment $comment): self
    {
        if (!$this->comments->contains($comment)) {
            $this->comments[] = $comment;
            $comment->setConference($this);
        }

        return $this;
    }

    public function removeComment(Comment $comment): self
    {
        if ($this->comments->removeElement($comment)) {
            // set the owning side to null (unless already changed)
            if ($comment->getConference() === $this) {
                $comment->setConference(null);
            }
        }

        return $this;
    }

    public function getSlug(): ?string
    {
        return $this->slug;
    }

    public function setSlug(string $slug): self
    {
        $this->slug = $slug;

        return $this;
    }
}
# src/entity/Comment.php
<?php

namespace App\Entity;

use App\Repository\CommentRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass=CommentRepository::class)
 * @ORM\HasLifecycleCallbacks()
 */
class Comment
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     * @Assert\NotBlank
     */
    private $author;

    /**
     * @ORM\Column(type="string", length=255)
     * @Assert\NotBlank
     */
    private $text;

    /**
     * @ORM\Column(type="string", length=255)
     * @Assert\NotBlank
     * @Assert\Email
     */
    private $email;

    /**
     * @ORM\Column(type="datetime")
     */
    private $createdAt;

    /**
     * @ORM\ManyToOne(targetEntity=Conference::class, inversedBy="comments")
     * @ORM\JoinColumn(nullable=false)
     */
    private $conference;

    /**
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    private $photoFilename;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function __toString(): string
    {
        return (string) $this->getEmail();
    }

    public function getAuthor(): ?string
    {
        return $this->author;
    }

    public function setAuthor(string $author): self
    {
        $this->author = $author;

        return $this;
    }

    public function getText(): ?string
    {
        return $this->text;
    }

    public function setText(string $text): self
    {
        $this->text = $text;

        return $this;
    }

    public function getEmail(): ?string
    {
        return $this->email;
    }

    public function setEmail(string $email): self
    {
        $this->email = $email;

        return $this;
    }

    public function getCreatedAt(): ?\DateTimeInterface
    {
        return $this->createdAt;
    }

    /**
     * @ORM\PrePersist
     */
    public function setCreatedAtValue(): self
    {
        $this->createdAt = new \DateTime();

        return $this;
    }

    public function setCreatedAt(\DateTimeInterface $createdAt): self
    {
        $this->createdAt = $createdAt;

        return $this;
    }

    public function getConference(): ?Conference
    {
        return $this->conference;
    }

    public function setConference(?Conference $conference): self
    {
        $this->conference = $conference;

        return $this;
    }

    public function getPhotoFilename(): ?string
    {
        return $this->photoFilename;
    }

    public function setPhotoFilename(?string $photoFilename): self
    {
        $this->photoFilename = $photoFilename;

        return $this;
    }
}

Doctrine Listener

# src/EntityListener/ConferenceEntityListener.php

namespace App\EntityListener;
use App\Entity\Conference;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\String\Slugger\SluggerInterface;

class ConferenceEntityListener
{
    private $slugger;

    public function __construct(SluggerInterface $slugger) 
    {
        $this->slugger=$slugger;
    }

    public function prePersist(Conference $conference, LifecycleEventArgs $event)
    {
        $conference->computeSlug($this->slugger);
    }

    public function preUpdate(Conference $conference, LifecycleEventArgs $event)
    {
        $conference->computeSlug($this->slugger);
    }
}
# config/services.yml
    App\EntityListener\ConferenceEntityListener:
        tags:
            - { name: 'doctrine.orm.entity_listener', event: 'prePersist', entity: 'App\Entity\Conference' }
            - { name: 'doctrine.orm.entity_listener', event: 'preUpdate', entity: 'App\Entity\Conference' }

Migrate

symfony console doctrine:database:create
symfony console make:migration
symfony console doctrine:migrations:migrate

Entities class:

public function __toString(): string
{
    return $this->city . ' ' . $this->year;
    return (string) $this->getEmail();

Panel EasyAdmin

symfony composer req admin:^2
# config/packages/easy_admin.yaml
easy_admin:
    site_name: Conference Guestbook
    entities:
            - App\Entity\Conference
            - App\Entity\Comment
# config/packages/easy_admin.yaml
easy_admin:
    site_name: Conference Guestbook
    design:
        menu:
            - { route: 'homepage', label: 'Inicio', icon: 'home' }
            - { entity: 'Conference', label: 'Conferences', icon: 'map-marker' }
            - { entity: 'Comment', label: 'Comment', icon: 'comments' }
    entities:
        Conference:
            class: App\Entity\Conference
        Comment:
            class: App\Entity\Comment
            list:
                fields:
                    - author
                    - { property: 'email', type: 'email' }
                    - { property: 'createdAt', type: 'datetime' }
                sort: ['createdAt', 'ASC']
                filters: ['conference' ]
            edit:
                fields:
                    - { property: 'conference' }
                    - { property: 'createdAt', type: datetime, type_options: { attr: { readonly: true }}}
                    - 'author'
                    - { property: 'email', type: 'email' }
                    - text

Controladores

# config/services.yaml
    bind:
        $photoDir: "%kernel.project_dir%/public/uploads/photos"
# src/Controller/ConferenceController.php
namespace App\Controller;

use App\Entity\Conference;
use App\Entity\Comment;
use App\Form\CommentFormType;
use App\Repository\CommentRepository;
use App\Repository\ConferenceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;

class ConferenceController extends AbstractController
{
    private $twig;
    private $entityManager;

    public function __construct(Environment $twig, EntityManagerInterface $entityManager)
    {
        $this->twig = $twig;
        $this->entityManager = $entityManager;
    }

    /**
     * @Route("/conference", name="conference_home")
     */
    public function index(Environment $twig, ConferenceRepository $conferenceRepository): Response
    {
        return new Response($twig->render('conference/index.html.twig', [
            'conferences' => $conferenceRepository->findAll(),
        ]));
    }

    /**
     * @Route("/conference/{slug}", name="conference")
     */
    public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository, string $photoDir): Response
    {
        $comment = new Comment();
        $form = $this->createForm(CommentFormType::class, $comment);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $comment->setConference($conference);

            if ($photo = $form['photo']->getData()) {
                $filename = bin2hex(random_bytes(6)).'.'.$photo->guessExtension();
                try {
                    $photo->move($photoDir, $filename);
                } catch (FileException $e) {
                    // unable to upload the photo, give up
                }
                $comment->setPhotoFilename($filename);
            }

            $this->entityManager->persist($comment);
            $this->entityManager->flush();

            return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);
        }

        $offset = max(0, $request->query->getInt('offset', 0));
        $paginator = $commentRepository->getCommentPaginator($conference, $offset);

        return new Response($twig->render('conference/show.html.twig', [
            'conference' => $conference,
            'comments' => $paginator,
            'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,
            'next' => min(count($paginator), $offset + CommentRepository::PAGINATOR_PER_PAGE),
            'comment_form' => $form->createView(),
            // 'comments' => $commentRepository->findBy(['conference' => $conference], ['createdAt' => 'DESC']),
        ]));
    }
}

Repositories

# ConferenceRepository.php
    public function findAll()
    {
        return $this->findBy([], ['year' => 'ASC', 'city' => 'ASC']);
    }

Paginator

# CommentRepository
use App\Entity\Conference;
use Doctrine\ORM\Tools\Pagination\Paginator;

    public const PAGINATOR_PER_PAGE = 2;

    public function getCommentPaginator(Conference $conference, int $offset):Paginator
    {
        $query = $this->createQueryBuilder('c')
            ->andWhere('c.conference = :conference')
            ->setParameter('conference', $conference)
            ->orderBy('c.createdAt', 'DESC')
            ->setMaxResults(self::PAGINATOR_PER_PAGE)
            ->setFirstResult($offset)
            ->getQuery()
        ;

        return new Paginator($query);
    }

Events

symfony console make:subscriber TwigEventSubscriber
# EventSubscriber/TwigEventSubscriber.php
use App\Repository\ConferenceRepository;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Environment\Twig;

class TwigEventSubscriber implements EventSubscriberInterface
{
    private $twig;
    private $conferenceRepository;

    public function __construct(Environment $twig, ConferenceRepository $conferenceRepository)
    {
        $this->twig = $twig;
        $this->conferenceRepository = $conferenceRepository;
    }

    public function onControllerEvent(ControllerEvent $event)
    {
        $this->twig->addGlobal('conferences', $this->conferenceRepository->findAll());
    }

    public static function getSubscribedEvents()
    {
        return [
            ControllerEvent::class => 'onControllerEvent',
        ];
    }
}

Formularios

symfony console make:form CommentFormType Comment
# src/Form/CommentFormType.php

namespace App\Form;

use App\Entity\Comment;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Image;

class CommentFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('author', null, [
                'label' => 'Your name'
            ])
            ->add('text')
            ->add('email', EmailType::class)
            ->add('photo', FileType::Class, [
                'required' => false,
                'mapped' => false,
                'constraints' => [
                    new Image(['maxSize' => '1024k'])
                ],
            ])
            ->add('submit', SubmitType::class)
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Comment::class,
        ]);
    }
}

Vista

Al instalar twig tendremos base.html.twig, y las demás plantillas la extenderán.

symfony composer req "twig:^3"

Para usar algunos filtros (format_datetime) necesitaremos:

symfony composer req "twig/intl-extra:^3"
# base.html.twig
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        {# Run `composer require symfony/webpack-encore-bundle`
           and uncomment the following Encore helpers to start using Symfony UX #}
        {% block stylesheets %}
            {# {{ encore_entry_link_tags('app') }} #}
        {% endblock %}

        {% block javascripts %}
            {# {{ encore_entry_script_tags('app') }} #}
        {% endblock %}
    </head>
    <body>
        <header>
            <h1><a href="{{ path("homepage") }}">Guestbook</a></h1>
            <ul>
            {% for conference in conferences %}
                <li><a href="{{ path("conference_show", {'slug': conference.slug}) }}">{{ conference }}</a></li>
            {% endfor %}
            </ul>
            <hr/>
        </header>
        {% block body %}{% endblock %}
    </body>
</html>
# templates/conference/index.html
{% extends 'base.html.twig' %}

{% block title %}Conference Guestbook!{% endblock %}

{% block body %}
{% endblock %}
# templates/conference/show.html.twig
{% extends'base.html.twig'%}

{% block title %}Conference Guestbook - {{ conference }}{% endblock %}

{% block body %}
    <h2>{{ conference }}Conference</h2>
    <div>There are {{ comments|length }} comments.</div>

    {% if comments | length > 0 %}
        {% for comment in comments %}
            {# {% if comment.photofilename %}
                <img src="{{ asset('uploads/photos/' ~ comment.photofilename )}}" />
            {% endif %} #}
            <h4>{{ comment.author }}</h4>
            <small>{{ comment.createdAt | format_datetime('medium','short') }}</small>
            <p>{{ comment.text }}</p>
        {% endfor %}
        {% if previous >= 0 %}
            <a href="{{ path('conference_show', { id: conference.slug, offset:previous }) }}">Previous</a>
        {% endif %}
        {% if next < comments|length %}
            <a href="{{ path('conference_show', { id: conference.slug, offset: next}) }}">Next</a>
        {% endif %}        
    {% else %}
        <div>No comments have been posted yet for this conference.</div>
    {% endif %}
    <p>
        <a href="{{ path("homepage") }}">Inicio</a>
    </p>
{% endblock %}

Installing Encore

composer require symfony/webpack-encore-bundle
yarn install
create the assets/ directory
add a webpack.config.js file
add node_modules/ to .gitignore

Uncomment the Twig helpers in templates/base.html.twig

Start the development server:

yarn encore dev-server

Resumen

echo "date.timezone=Europe\Madrid"\n >> php.ini
echo 'intl.default_locale = "es_ES.UTF-8"\n' >> php.ini

Configuramos

sf new --version=5.2 --webapp --php=7.4 . 
sf composer rem webpack
sf composer req admin:^2
sf composer req "twig/intl-extra"
DATABASE_URL
REDIS_URL=redis://localhost:6379?timeout=5
handler_id: '%env(REDIS_URL)%'

esquema

sf console make:entity Comment (author,text,email,createdAt, photoFilename)
sf console make:entity Conference (city,year,isInternational, slug, comments)
sf console make:migration
sf console doctrine:migrations:migrate
easy_admin.yaml
__toString()

controladores

sf console make:controller ConferenceController
modify ConferenceController::index (twig, conferenceRepository)
modify conference/index.html.twig
add ConferenceController::show
add conference/show.html.twig ( | format_datetime('medium', 'short'))
commentRepository::getCommentPaginator
ConferenceController::show(Request...) paginator, previous and next
conference/show.html.twig (count, previous, next)

Twig Event Subscriber

sf console make:subscriber TwigEventSubscriber (inject Twig and Conference)
$this->twig->addGlobal('var',val)
header en base.html.twig
ConferenceRepository finfAll findBy

Lifecycle

@ORM\HasLifecycleCallbacks()
--
@ORM\PrePersist

migrate database for slug compatibility

$this->addSql('ALTER TABLE conference ADD slug VARCHAR(255)');
$this->addSql"UPDATE conference SET slug=CONCAT(LOWER(city), '-', year)");
$this->addSql('ALTER TABLE conference ALTER COLUMN slug SET NOT NULL');
@UniqueEntity("slug")
unique=true
Conference::computeSlug(SluggerInterface $slugger)
namespace App\EntityListener;
class ConferenceEntityListener

# config/services.yml
    App\EntityListener\ConferenceEntityListener:
        tags:
            - { name: 'doctrine.orm.entity_listener', event: 'prePersist', entity: 'App\Entity\Conference' }
            - { name: 'doctrine.orm.entity_listener', event: 'preUpdate', entity: 'App\Entity\Conference' }

refactor (conference.slug)
# src/EntityListener/ConferenceEntityListener.php

namespace App\EntityListener;
use App\Entity\Conference;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\String\Slugger\SluggerInterface;

class ConferenceEntityListener
{
    private $slugger;

    public function __construct(SluggerInterface $slugger) 
    {
        $this->slugger=$slugger;
    }

    public function prePersist(Conference $conference, LifecycleEventArgs $event)
    {
        $conference->computeSlug($this->slugger);
    }

    public function preUpdate(Conference $conference, LifecycleEventArgs $event)
    {
        $conference->computeSlug($this->slugger);
    }
}
# config/services.yml
    App\EntityListener\ConferenceEntityListener:
        tags:
            - { name: 'doctrine.orm.entity_listener', event: 'prePersist', entity: 'App\Entity\Conference' }
            - { name: 'doctrine.orm.entity_listener', event: 'preUpdate', entity: 'App\Entity\Conference' }

forms

sf console make:form ConferenceTypeForm Comment
customize
ConferenceController (new Comment and form)
show.html.twig {{ form(comment_form) }}
Comment entity (asserts)
use Symfony\Component\Validator\Constraints as Assert;

Handling the form

        $form->handleRequest($request);
        if($form->isSubmitted() && $form->isValid()) {
            $comment->setConference($conference);

            if($photo = $form['photo']->getData()) {
                $filename = bin2hex(random_bytes(6)) . '.' . $photo->guessExtension();
                try {
                    $photo->move($photoDir, $filename);
                } catch (FileException $e) {

                }
                $comment->setPhotoFilename($filename);
            }

            $commentRepository->add($comment, true);

            return $this->redirectToRoute("conference", ['slug' => $conference->getSlug()]);
        }
    _defaults:
        bind:
            string $photoDir: "%kernel.project_dir%/public/uploads/"

.gitignore

/public/uploads

securing the admin

sf console make:user Admin
public function __toString(): string
{
    return $this->username;
}
migrate
symfony console security:encode-password
symfony run psql -c "INSERT INTO admin (id, username, roles, password) \
VALUES (nextval('admin_id_seq'), 'admin', '[\"ROLE_ADMIN\"]', \
'\__ENCODEDPASSWORD__')"
sf console make:auth
onAuthenticationSuccess()
return new RedirectResponse($this->urlGenerator->generate('easyadmin'));
- { path: ^/admin, roles: ROLE_ADMIN }

Tests

sf composer req phpunit --dev
sf composer require browser-kit --dev
sf console make:test [TestCase SpamCheckerTest]
sf php bin/phpunit
APP_ENV=test sf console secrets:set AKISMET_KEY
sf run bin/phpunit tests/Controller/ConferenceControllerTest.php
$client->getResponse() y echo

Fixtures

sf composer req orm-fixtures --dev
sf console doctrine:fixtures:load --env=test

Siguiente Publicación