Sign-In : use symfony form component (with Bootstrap)

master
Julien Rosset 5 years ago
parent fc23008e57
commit fc96e0edff

@ -0,0 +1,11 @@
form {
text-align: left;
label {
text-align: right;
}
.form-buttons {
text-align: center;
}
}

@ -8,6 +8,7 @@ header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
z-index: 9999;
nav a { nav a {
margin: 0 5px; margin: 0 5px;

@ -5,8 +5,16 @@
body { body {
@extend %font; @extend %font;
margin: 80px 5px 0 5px; // Vertical translation for the fixed header margin: 80px 5px 0 5px; // Vertical translation for the fixed header
} }
.ui-widget { .ui-widget {
@extend %font; @extend %font;
}
pre.xdebug-var-dump {
font-family: sans-serif;
font-size: 1em;
text-align: left;
background-color: rgba(255, 255, 255, 1);
} }

@ -5,4 +5,5 @@
@import "../components/messages"; @import "../components/messages";
@import "../layouts/page"; @import "../layouts/page";
@import "../layouts/header"; @import "../layouts/header";
@import "../layouts/form";

@ -3,9 +3,10 @@ body {
} }
#sign-in { #sign-in {
display: inline-block; width: 50%;
border: 1px solid rgba(0, 0, 0, .25); border: 1px solid rgba(0, 0, 0, .25);
padding: 10px; padding: 10px;
margin: 0 auto;
} }
#presentation { #presentation {

@ -2,39 +2,42 @@
"type": "project", "type": "project",
"license": "proprietary", "license": "proprietary",
"require": { "require": {
"php": "^7.4.2", "php": "^7.4.2",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"ext-intl": "^7.4", "ext-intl": "^7.4",
"ext-sodium": "^7.4", "ext-sodium": "^7.4",
"doctrine/doctrine-bundle": "*", "doctrine/annotations": "^1.10",
"doctrine/doctrine-bundle": "*",
"doctrine/doctrine-migrations-bundle": "2.*.*", "doctrine/doctrine-migrations-bundle": "2.*.*",
"doctrine/orm": "*", "doctrine/orm": "*",
"sensio/framework-extra-bundle": "^5.5", "sensio/framework-extra-bundle": "^5.5",
"symfony/apache-pack": "^1.0", "symfony/apache-pack": "^1.0",
"symfony/asset": "5.*.*", "symfony/asset": "5.*.*",
"symfony/console": "5.*.*", "symfony/console": "5.*.*",
"symfony/dotenv": "5.*.*", "symfony/dotenv": "5.*.*",
"symfony/expression-language": "5.*.*", "symfony/expression-language": "5.*.*",
"symfony/flex": "^1.3.1", "symfony/flex": "^1.3.1",
"symfony/form": "5.*.*", "symfony/form": "5.*.*",
"symfony/framework-bundle": "5.*.*", "symfony/framework-bundle": "5.*.*",
"symfony/http-client": "5.*.*", "symfony/http-client": "5.*.*",
"symfony/intl": "5.*.*", "symfony/intl": "5.*.*",
"symfony/mailer": "5.*.*", "symfony/mailer": "5.*.*",
"symfony/monolog-bundle": "^3.1", "symfony/monolog-bundle": "^3.1",
"symfony/notifier": "5.*.*", "symfony/notifier": "5.*.*",
"symfony/process": "5.*.*", "symfony/process": "5.*.*",
"symfony/profiler-pack": "^1.0", "symfony/profiler-pack": "^1.0",
"symfony/security-bundle": "5.*.*", "symfony/property-info": "5.*.*",
"symfony/serializer-pack": "*", "symfony/security-bundle": "5.*.*",
"symfony/string": "5.*.*", "symfony/security-csrf": "5.*.*",
"symfony/translation": "5.*.*", "symfony/serializer-pack": "*",
"symfony/twig-pack": "^1.0", "symfony/string": "5.*.*",
"symfony/validator": "5.*.*", "symfony/translation": "5.*.*",
"symfony/web-link": "5.*.*", "symfony/twig-pack": "^1.0",
"symfony/webpack-encore-bundle": "^1.7", "symfony/validator": "5.*.*",
"symfony/yaml": "5.*.*" "symfony/web-link": "5.*.*",
"symfony/webpack-encore-bundle": "^1.7",
"symfony/yaml": "5.*.*"
}, },
"require-dev": { "require-dev": {
"symfony/debug-pack": "*", "symfony/debug-pack": "*",

92
composer.lock generated

@ -1,20 +1,20 @@
{ {
"_readme": [ "_readme": [
"This file locks the dependencies of your project to a known state", "This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "1988f77310fab5e902b0c0cf2be42503", "content-hash": "b95652040ba10ba1825a39e3277a3516",
"packages": [ "packages": [
{ {
"name": "doctrine/annotations", "name": "doctrine/annotations",
"version": "1.10.3", "version": "1.10.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/doctrine/annotations.git", "url": "https://github.com/doctrine/annotations.git",
"reference": "5db60a4969eba0e0c197a19c077780aadbc43c5d" "reference": "5db60a4969eba0e0c197a19c077780aadbc43c5d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/doctrine/annotations/zipball/5db60a4969eba0e0c197a19c077780aadbc43c5d", "url": "https://api.github.com/repos/doctrine/annotations/zipball/5db60a4969eba0e0c197a19c077780aadbc43c5d",
"reference": "5db60a4969eba0e0c197a19c077780aadbc43c5d", "reference": "5db60a4969eba0e0c197a19c077780aadbc43c5d",
@ -6372,24 +6372,24 @@
"time": "2020-05-20T17:43:50+00:00" "time": "2020-05-20T17:43:50+00:00"
}, },
{ {
"name": "twig/extra-bundle", "name": "twig/extra-bundle",
"version": "v3.0.3", "version": "v3.0.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/twig-extra-bundle.git", "url": "https://github.com/twigphp/twig-extra-bundle.git",
"reference": "6eaf1637abe6b68518e7e0949ebb84e55770d5c6" "reference": "a7c5799cf742ab0827f5d32df37528ee8bf5a233"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6eaf1637abe6b68518e7e0949ebb84e55770d5c6", "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/a7c5799cf742ab0827f5d32df37528ee8bf5a233",
"reference": "6eaf1637abe6b68518e7e0949ebb84e55770d5c6", "reference": "a7c5799cf742ab0827f5d32df37528ee8bf5a233",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.1.3", "php": "^7.1.3|^8.0",
"symfony/framework-bundle": "^4.3|^5.0", "symfony/framework-bundle": "^4.3|^5.0",
"symfony/twig-bundle": "^4.3|^5.0", "symfony/twig-bundle": "^4.3|^5.0",
"twig/twig": "^2.4|^3.0" "twig/twig": "^2.4|^3.0"
}, },
"require-dev": { "require-dev": {
"twig/cssinliner-extra": "^2.12|^3.0", "twig/cssinliner-extra": "^2.12|^3.0",
@ -6428,32 +6428,32 @@
"extra", "extra",
"twig" "twig"
], ],
"time": "2020-01-01T17:11:09+00:00" "time": "2020-05-21T09:56:39+00:00"
}, },
{ {
"name": "twig/twig", "name": "twig/twig",
"version": "v3.0.3", "version": "v3.0.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/Twig.git", "url": "https://github.com/twigphp/Twig.git",
"reference": "3b88ccd180a6b61ebb517aea3b1a8906762a1dc2" "reference": "582bdbdc173027ebfba3c93dc750a40b8f9ebc02"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/3b88ccd180a6b61ebb517aea3b1a8906762a1dc2", "url": "https://api.github.com/repos/twigphp/Twig/zipball/582bdbdc173027ebfba3c93dc750a40b8f9ebc02",
"reference": "3b88ccd180a6b61ebb517aea3b1a8906762a1dc2", "reference": "582bdbdc173027ebfba3c93dc750a40b8f9ebc02",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2.5", "php": ">=7.2.5",
"symfony/polyfill-ctype": "^1.8", "symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-mbstring": "^1.3" "symfony/polyfill-mbstring": "^1.3"
}, },
"require-dev": { "require-dev": {
"psr/container": "^1.0", "psr/container": "^1.0",
"symfony/phpunit-bridge": "^4.4|^5.0" "symfony/phpunit-bridge": "^4.4.9|^5.0.9"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "3.0-dev" "dev-master": "3.0-dev"
@ -6490,7 +6490,7 @@
"keywords": [ "keywords": [
"templating" "templating"
], ],
"time": "2020-02-11T15:33:47+00:00" "time": "2020-07-05T13:18:14+00:00"
}, },
{ {
"name": "webimpress/safe-writer", "name": "webimpress/safe-writer",

@ -15,3 +15,9 @@ framework:
#fragments: true #fragments: true
php_errors: php_errors:
log: true log: true
csrf_protection: true
validation:
enabled: true
enable_annotations: true

@ -0,0 +1,8 @@
# As of Symfony 5.1, deprecations are logged in the dedicated "deprecation" channel when it exists
#monolog:
# channels: [deprecation]
# handlers:
# deprecation:
# type: stream
# channels: [deprecation]
# path: "%kernel.logs_dir%/%kernel.environment%.deprecations.log"

@ -1,7 +1,12 @@
security: security:
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers encoders:
App\Entity\User:
algorithm: auto
providers: providers:
users_in_memory: { memory: null } app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls: firewalls:
dev: dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
@ -9,16 +14,18 @@ security:
main: main:
anonymous: true anonymous: true
lazy: true lazy: true
provider: users_in_memory remember_me:
secret: '%kernel.secret%'
# activate different ways to authenticate lifetime: 604800 # 1 week
# https://symfony.com/doc/current/security.html#firewalls-authentication path: /
secure: true
# https://symfony.com/doc/current/security/impersonating_user.html guard:
# switch_user: true authenticators:
- App\Security\LoginFormAuthenticator
# Easy way to control access for large sections of your site logout:
# Note: Only the *first* access control that matches will be used path: app_security_logout
target: app_site_index
access_control: access_control:
# - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/sign-in$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
# - { path: ^/profile, roles: ROLE_USER } role_hierarchy:
ROLE_ADMIN: ROLE_USER

@ -1,2 +0,0 @@
#webpack_encore:
# strict_mode: false

@ -1,2 +1,3 @@
twig: twig:
default_path: '%kernel.project_dir%/templates' default_path: '%kernel.project_dir%/templates'
form_themes: ['bootstrap_4_horizontal_layout.html.twig']

@ -1,3 +1,8 @@
#index: # Redirige l'URL racine vers celle de la langue par défaut
# path: / index:
# controller: App\Controller\DefaultController::index path: /
controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController
defaults:
route: 'app_site_index'
_locale: '%kernel.default_locale%'
permanent: true

@ -1,6 +1,11 @@
controllers: controllers:
resource: ../../src/Controller/ resource: ../../src/Controller/
type: annotation type: annotation
prefix: /{_locale}
requirements:
_locale: '%app.supported_locales%'
defaults:
_locale: '%kernel.default_locale%'
kernel: kernel:
resource: ../../src/Kernel.php resource: ../../src/Kernel.php

@ -4,6 +4,7 @@
# Put parameters here that don't need to change on each machine where the app is deployed # Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters: parameters:
app.supported_locales: 'fr|en'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file

@ -2,6 +2,7 @@
namespace App\Controller; namespace App\Controller;
use App\Form\SecuritySignInForm;
use Exception; use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@ -18,7 +19,7 @@ class SecurityController extends AbstractController {
/** /**
* Show login form * Show login form
* *
* @param TranslatorInterface $translator Interface for translation * @param TranslatorInterface $translator Interface for translation
* @param AuthenticationUtils $authenticationUtils Helper for authentication information * @param AuthenticationUtils $authenticationUtils Helper for authentication information
* *
* @return Response Page response * @return Response Page response
@ -26,15 +27,23 @@ class SecurityController extends AbstractController {
* @Route("/sign-in") * @Route("/sign-in")
*/ */
public function sign_in (TranslatorInterface $translator, AuthenticationUtils $authenticationUtils): Response { public function sign_in (TranslatorInterface $translator, AuthenticationUtils $authenticationUtils): Response {
$error = $authenticationUtils->getLastAuthenticationError(); $form = $this->createForm(
if ($error !== null) { SecuritySignInForm::class,
$this->addFlash('error', $translator->trans($error->getMessageKey(), $error->getMessageData(), 'security')); [
} 'email' => $authenticationUtils->getLastUsername(),
],
[
// 'attr' => [
// 'novalidate' => 'novalidate',
// ],
]
);
return $this->render( return $this->render(
'security/sign-in.html.twig', 'security/sign-in.html.twig',
[ [
'username' => $authenticationUtils->getLastUsername(), 'error' => $authenticationUtils->getLastAuthenticationError(),
'form' => $form->createView(),
] ]
); );
} }

@ -8,6 +8,8 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
/** /**
* User entity
*
* @ORM\Entity(repositoryClass="App\Repository\UserRepository") * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
*/ */
class User implements UserInterface { class User implements UserInterface {

@ -0,0 +1,21 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
abstract class AbstractBootstrapForm extends AbstractType {
public function configureOptions (OptionsResolver $resolver) {
parent::configureOptions($resolver);
$resolver->setDefaults(
[
'attr' => [
'class' => 'form-validation',
'novalidate' => 'novalidate',
],
]
);
}
}

@ -0,0 +1,50 @@
<?php
namespace App\Form;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Security : sign-in form
*
* @package App\Form
*/
class SecuritySignInForm extends AbstractBootstrapForm {
public function buildForm (FormBuilderInterface $builder, array $options) {
parent::buildForm($builder, $options);
$builder
->add(
'email',
EmailType::class,
[
'label_format' => 'user.email',
]
)
->add(
'password',
PasswordType::class,
[
'label_format' => 'user.password',
]
)
->add(
'_remember_me',
CheckboxType::class,
[
'label_format' => 'sign.remember_me',
'required' => false,
]
);
}
public function configureOptions (OptionsResolver $resolver) {
parent::configureOptions($resolver);
$resolver->setDefault('translation_domain', 'security');
}
}

@ -3,19 +3,18 @@
namespace App\Security; namespace App\Security;
use App\Entity\User; use App\Entity\User;
use App\Form\SecuritySignInForm;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator; use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;
use Symfony\Component\Security\Guard\PasswordAuthenticatedInterface; use Symfony\Component\Security\Guard\PasswordAuthenticatedInterface;
use Symfony\Component\Security\Http\Util\TargetPathTrait; use Symfony\Component\Security\Http\Util\TargetPathTrait;
@ -25,19 +24,19 @@ class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements P
private EntityManagerInterface $entityManager; private EntityManagerInterface $entityManager;
private UrlGeneratorInterface $urlGenerator; private UrlGeneratorInterface $urlGenerator;
private CsrfTokenManagerInterface $csrfTokenManager;
private UserPasswordEncoderInterface $passwordEncoder; private UserPasswordEncoderInterface $passwordEncoder;
private FormFactoryInterface $formFactory;
public function __construct ( public function __construct (
EntityManagerInterface $entityManager, EntityManagerInterface $entityManager,
UrlGeneratorInterface $urlGenerator, UrlGeneratorInterface $urlGenerator,
CsrfTokenManagerInterface $csrfTokenManager, UserPasswordEncoderInterface $passwordEncoder,
UserPasswordEncoderInterface $passwordEncoder FormFactoryInterface $formFactory
) { ) {
$this->entityManager = $entityManager; $this->entityManager = $entityManager;
$this->urlGenerator = $urlGenerator; $this->urlGenerator = $urlGenerator;
$this->csrfTokenManager = $csrfTokenManager;
$this->passwordEncoder = $passwordEncoder; $this->passwordEncoder = $passwordEncoder;
$this->formFactory = $formFactory;
} }
public function supports (Request $request) { public function supports (Request $request) {
@ -45,11 +44,17 @@ class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements P
} }
public function getCredentials (Request $request) { public function getCredentials (Request $request) {
$credentials = [ if (!$request->isMethod('POST') || $request->attributes->get('_route') != 'app_security_sign_in') {
'email' => $request->request->get('email'), return null;
'password' => $request->request->get('password'), }
'csrf_token' => $request->request->get('_csrf_token'),
]; $form = $this->formFactory->create(SecuritySignInForm::class);
$form->handleRequest($request);
if (!$form->isSubmitted() || !$form->isValid()) {
return null;
}
$credentials = $form->getData();
$request->getSession()->set( $request->getSession()->set(
Security::LAST_USERNAME, Security::LAST_USERNAME,
$credentials['email'] $credentials['email']
@ -59,15 +64,8 @@ class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements P
} }
public function getUser ($credentials, UserProviderInterface $userProvider) { public function getUser ($credentials, UserProviderInterface $userProvider) {
$token = new CsrfToken('authenticate', $credentials['csrf_token']);
if (!$this->csrfTokenManager->isTokenValid($token)) {
throw new InvalidCsrfTokenException();
}
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $credentials['email']]); $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $credentials['email']]);
if (!$user) { if (!$user) {
// fail authentication with a custom error
throw new CustomUserMessageAuthenticationException('Email could not be found.'); throw new CustomUserMessageAuthenticationException('Email could not be found.');
} }

@ -11,31 +11,23 @@
{% block body %} {% block body %}
<section id="sign-in"> <section id="sign-in">
<h1>{{ 'sign.in'|trans }}</h1> <h1>{{ 'sign.in'|trans }}</h1>
<form method="POST" class="form-validation" novalidate>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<div class="form-group"> {% if error %}
<label for="email" class="sr-only">{{ 'user.email'|trans }}</label> <div class="alert alert-danger" role="alert">
<input type="email" value="{{ username }}" name="email" id="email" class="form-control" placeholder="{{ 'user.email'|trans }}" required autofocus> <span class="sr-only">{{ 'error'|trans({}, 'messages') }}</span>
<div class="invalid-feedback">{{ 'user.invalid.email'|trans }}</div> {{ error.messageKey|trans(error.messageData) }}
</div> </div>
{% endif %}
<div class="form-group">
<label for="password" class="sr-only">{{ 'user.password'|trans }}</label> {{ form_start(form) }}
<input type="password" name="password" id="password" class="form-control" placeholder="{{ 'user.password'|trans }}" required> {{ form_row(form.email) }}
<div class="invalid-feedback">{{ 'user.invalid.password'|trans }}</div> {{ form_row(form.password) }}
</div> {{ form_row(form._remember_me) }}
<div class="form-group form-buttons">
<div class="form-group"> <button class="btn btn-lg btn-primary" type="submit">{{ 'sign.in'|trans }}</button>
<input type="checkbox" name="_remember_me" id="remember_me"> <a class="btn btn-lg btn-secondary" href="#">{{ 'sign.up'|trans }}</a>
<label for="remember_me">{{ 'sign.remember_me'|trans }}</label> </div>
</div> {{ form_end(form) }}
<div class="form-group">
<button class="btn btn-lg btn-primary" type="submit">{{ 'sign.in'|trans }}</button>
<a class="btn btn-lg btn-secondary" href="#">{{ 'sign.up'|trans }}</a>
</div>
</form>
</section> </section>
<section id="presentation"> <section id="presentation">

@ -0,0 +1,12 @@
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
if (file_exists(dirname(__DIR__) . '/config/bootstrap.php')) {
require dirname(__DIR__) . '/config/bootstrap.php';
}
elseif (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');
}
Loading…
Cancel
Save