`:
+
+.. code-block:: html+twig
+
+ {% set voter_decision = access_decision('post_edit', post) %}
+ {% if voter_decision.isGranted() %}
+ {# ... #}
+ {% else %}
+ {# before showing voter messages to end users, make sure it's safe to do so #}
+ {{ voter_decision.message }}
+ {% endif %}
+
+ {% set voter_decision = access_decision('post_edit', post, anotherUser) %}
+ {% if voter_decision.isGranted() %}
+ {# ... #}
+ {% else %}
+ The {{ anotherUser.name }} user doesn't have sufficient permission:
+ {# before showing voter messages to end users, make sure it's safe to do so #}
+ {{ voter_decision.message }}
+ {% endif %}
+
+.. versionadded:: 7.4
+
+ The ``access_decision()`` and ``access_decision_for_user()`` Twig functions
+ were introduced in Symfony 7.4.
+
.. _security-isgrantedforuser:
Securing other Services
@@ -2599,6 +2688,42 @@ want to include extra details only for users that have a ``ROLE_SALES_ADMIN`` ro
The :method:`Symfony\\Bundle\\SecurityBundle\\Security::isGrantedForUser`
method was introduced in Symfony 7.3.
+You can also use the ``getAccessDecision()`` and ``getAccessDecisionForUser()``
+methods to check authorization and get to retrieve the reasons for denying
+permission in :ref:`your custom security voters `::
+
+ // src/SalesReport/SalesReportManager.php
+
+ // ...
+ use Symfony\Bundle\SecurityBundle\Security;
+
+ class SalesReportManager
+ {
+ public function __construct(
+ private Security $security,
+ ) {
+ }
+
+ public function generateReport(): void
+ {
+ $voterDecision = $this->security->getAccessDecision('ROLE_SALES_ADMIN');
+ if ($voterDecision->isGranted('ROLE_SALES_ADMIN')) {
+ // ...
+ } else {
+ // do something with $voterDecision->getMessage()
+ }
+
+ // ...
+ }
+
+ // ...
+ }
+
+.. versionadded:: 7.4
+
+ The ``getAccessDecision()`` and ``getAccessDecisionForUser()`` methods
+ were introduced in Symfony 7.4.
+
If you're using the :ref:`default services.yaml configuration `,
Symfony will automatically pass the ``security.helper`` to your service
thanks to autowiring and the ``Security`` type-hint.
@@ -3023,3 +3148,4 @@ Authorization (Denying Access)
.. _`Login CSRF attacks`: https://en.wikipedia.org/wiki/Cross-site_request_forgery#Forging_login_requests
.. _`PHP date relative formats`: https://www.php.net/manual/en/datetime.formats.php#datetime.formats.relative
.. _`Oauth2-client`: https://github.com/thephpleague/oauth2-client
+.. _`Mermaid CLI`: https://github.com/mermaid-js/mermaid-cli
diff --git a/security/access_token.rst b/security/access_token.rst
index 2c070f72e92..1729540af10 100644
--- a/security/access_token.rst
+++ b/security/access_token.rst
@@ -877,6 +877,120 @@ create your own User from the claims, you must
}
}
+Configuring Multiple OIDC Discovery Endpoints
+.............................................
+
+.. versionadded:: 7.4
+
+ Support for multiple OIDC discovery endpoints was introduced in Symfony 7.4.
+
+The ``OidcTokenHandler`` supports multiple OIDC discovery endpoints, allowing it
+to validate tokens from different identity providers:
+
+.. configuration-block::
+
+ .. code-block:: yaml
+
+ # config/packages/security.yaml
+ security:
+ firewalls:
+ main:
+ access_token:
+ token_handler:
+ oidc:
+ algorithms: ['ES256', 'RS256']
+ audience: 'api-example'
+ issuers: ['https://oidc1.example.com', 'https://oidc2.example.com']
+ discovery:
+ base_uri:
+ - https://idp1.example.com/realms/demo/
+ - https://idp2.example.com/realms/demo/
+ cache:
+ id: cache.app
+
+ .. code-block:: xml
+
+
+
+
+
+
+
+
+
+
+ ES256
+ RS256
+ https://oidc1.example.com
+ https://oidc2.example.com
+
+ https://idp1.example.com/realms/demo/
+ https://idp2.example.com/realms/demo/
+
+
+
+
+
+
+
+
+ .. code-block:: php
+
+ // config/packages/security.php
+ use Symfony\Config\SecurityConfig;
+
+ return static function (SecurityConfig $security) {
+ $security->firewall('main')
+ ->accessToken()
+ ->tokenHandler()
+ ->oidc()
+ ->algorithms(['ES256', 'RS256'])
+ ->audience('api-example')
+ ->issuers(['https://oidc1.example.com', 'https://oidc2.example.com'])
+ ->discovery()
+ ->baseUri([
+ 'https://idp1.example.com/realms/demo/',
+ 'https://idp2.example.com/realms/demo/',
+ ])
+ ->cache(['id' => 'cache.app'])
+ ;
+ };
+
+The token handler fetches the JWK sets from all configured discovery endpoints
+and builds a combined JWK set for token validation. This lets your application
+accept and validate tokens from multiple identity providers within a single firewall.
+
+Creating a OIDC token from the command line
+-------------------------------------------
+
+.. versionadded:: 7.4
+
+ The ``security:oidc:generate-token`` command was introduced in Symfony 7.4.
+
+The ``security:oidc:generate-token`` command helps you generate JWTs. It's mostly
+useful when developing or testing applications that use OIDC authentication:
+
+.. code-block:: terminal
+
+ # generate a token using the default configuration
+ $ php bin/console security:oidc:generate-token john.doe@example.com
+
+ # specify the firewall, algorithm, and issuer if multiple are available
+ $ php bin/console security:oidc:generate-token john.doe@example.com \
+ --firewall="api" \
+ --algorithm="HS256" \
+ --issuer="https://example.com"
+
+.. note::
+
+ The JWK used for signing must have the appropriate `key operation flags`_ set.
+
Using CAS 2.0
-------------
@@ -1099,3 +1213,4 @@ for :ref:`stateless firewalls `.
.. _`OpenID Connect Discovery`: https://openid.net/specs/openid-connect-discovery-1_0.html
.. _`RFC6750`: https://datatracker.ietf.org/doc/html/rfc6750
.. _`SAML2 (XML structures)`: https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html
+.. _`key operation flags`: https://www.iana.org/assignments/jose/jose.xhtml#web-key-operations
diff --git a/security/csrf.rst b/security/csrf.rst
index 295a43fd9ff..48a5f3f03e0 100644
--- a/security/csrf.rst
+++ b/security/csrf.rst
@@ -321,6 +321,27 @@ array, the attribute is ignored for that request, and no CSRF validation occurs:
// ... delete the object
}
+You can also choose where the CSRF token is read from using the ``tokenSource``
+parameter. This is a bitfield that allows you to combine different sources:
+
+* ``IsCsrfTokenValid::SOURCE_PAYLOAD`` (default): request payload (POST body / json)
+* ``IsCsrfTokenValid::SOURCE_QUERY``: query string
+* ``IsCsrfTokenValid::SOURCE_HEADER``: request header
+
+Example::
+
+ #[IsCsrfTokenValid(
+ 'delete-item',
+ tokenKey: 'token',
+ tokenSource: IsCsrfTokenValid::SOURCE_PAYLOAD | IsCsrfTokenValid::SOURCE_QUERY
+ )]
+ public function delete(Post $post): Response
+ {
+ // ... delete the object
+ }
+
+The token is checked against each selected source, and validation fails if none match.
+
.. versionadded:: 7.1
The :class:`Symfony\\Component\\Security\\Http\\Attribute\\IsCsrfTokenValid`
@@ -330,6 +351,10 @@ array, the attribute is ignored for that request, and no CSRF validation occurs:
The ``methods`` parameter was introduced in Symfony 7.3.
+.. versionadded:: 7.4
+
+ The ``tokenSource`` parameter was introduced in Symfony 7.4.
+
CSRF Tokens and Compression Side-Channel Attacks
------------------------------------------------
diff --git a/security/passwords.rst b/security/passwords.rst
index 7f05bc3acb9..5de5d4b7b24 100644
--- a/security/passwords.rst
+++ b/security/passwords.rst
@@ -256,6 +256,64 @@ You can customize the reset password bundle's behavior by updating the
``reset_password.yaml`` file. For more information on the configuration,
check out the `SymfonyCastsResetPasswordBundle`_ guide.
+Injecting a Specific Password Hasher
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+In some cases, you may define a password hasher in your configuration that is
+not tied to a user class. For example, you might use a separate hasher for
+password recovery codes or API tokens.
+
+With the following configuration:
+
+.. code-block:: yaml
+
+ # config/packages/security.yaml
+ security:
+ password_hashers:
+ recovery_code: 'auto'
+
+ firewalls:
+ main:
+ # ...
+
+You can inject the ``recovery_code`` password hasher into any service. However,
+you can't rely on standard autowiring, as Symfony doesn't know which specific
+hasher to provide.
+
+Instead, use the ``#[Target]`` attribute to explicitly request the hasher by
+its configuration key::
+
+ // src/Controller/HomepageController.php
+ namespace App\Controller;
+
+ use Symfony\Component\DependencyInjection\Attribute\Target;
+ use Symfony\Component\PasswordHasher\PasswordHasherInterface;
+
+ class HomepageController extends AbstractController
+ {
+ public function __construct(
+ #[Target('recovery_code')]
+ private readonly PasswordHasherInterface $passwordHasher,
+ ) {
+ }
+
+ #[Route('/')]
+ public function index(): Response
+ {
+ $plaintextToken = 'some-secret-token';
+
+ // Note: use hash(), not hashPassword(), as we are not using a UserInterface object
+ $hashedToken = $this->passwordHasher->hash($plaintextToken);
+ }
+ }
+
+When injecting a specific hasher by its name, you should type-hint the generic
+:class:`Symfony\\Component\\PasswordHasher\\PasswordHasherInterface`.
+
+.. versionadded:: 7.4
+
+ The feature to inject specific password hashers was introduced in Symfony 7.4.
+
.. _security-password-migration:
Password Migration
diff --git a/security/voters.rst b/security/voters.rst
index 3543eae86c0..121aa22e435 100644
--- a/security/voters.rst
+++ b/security/voters.rst
@@ -124,6 +124,8 @@ calls out to the "voter" system. Right now, no voters will vote on whether or no
the user can "view" or "edit" a ``Post``. But you can create your *own* voter that
decides this using whatever logic you want.
+.. _creating-the-custom-voter:
+
Creating the custom Voter
-------------------------
@@ -210,6 +212,17 @@ would look like this::
}
}
+.. tip::
+
+ Votes define an ``$extraData`` property that you can use to store any data
+ that you might need later::
+
+ $vote->extraData['key'] = 'value'; // values can be of any type
+
+ .. versionadded:: 7.4
+
+ The ``$extraData`` property was introduced in Symfony 7.4.
+
That's it! The voter is done! Next, :ref:`configure it `.
To recap, here's what's expected from the two abstract methods:
@@ -510,6 +523,60 @@ option to use a custom service (your service must implement the
;
};
+When creating custom decision strategies, you can store additional data in votes
+to be used later when making a decision. For example, if not all votes should
+have the same weight, you could store a ``score`` value for each vote::
+
+ // src/Security/PostVoter.php
+ namespace App\Security;
+
+ use App\Entity\Post;
+ use App\Entity\User;
+ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+ use Symfony\Component\Security\Core\Authorization\Voter\Vote;
+ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+
+ class PostVoter extends Voter
+ {
+ // ...
+
+ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
+ {
+ // ...
+ $vote->extraData['score'] = 10;
+
+ // ...
+ }
+ }
+
+Then, access that value when counting votes to make a decision::
+
+ // src/Security/MyCustomAccessDecisionStrategy.php
+ use Symfony\Component\Security\Core\Authorization\Strategy\AccessDecisionStrategyInterface;
+
+ class MyCustomAccessDecisionStrategy implements AccessDecisionStrategyInterface
+ {
+ public function decide(\Traversable $results, $accessDecision = null): bool
+ {
+ $score = 0;
+
+ foreach ($results as $key => $result) {
+ $vote = $accessDecision->votes[$key];
+ if (array_key_exists('score', $vote->extraData)) {
+ $score += $vote->extraData['score'];
+ } else {
+ $score += $vote->result;
+ }
+ }
+
+ // ...
+ }
+ }
+
+.. versionadded:: 7.4
+
+ The feature to store arbitrary data inside votes was introduced in Symfony 7.4.
+
Custom Access Decision Manager
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/serializer.rst b/serializer.rst
index 0fb8f3039c0..d2bd9bcbc1f 100644
--- a/serializer.rst
+++ b/serializer.rst
@@ -1476,10 +1476,14 @@ normalizers (in order of priority):
to read and write in the object. This allows it to access properties
directly or using getters, setters, hassers, issers, canners, adders and
removers. Names are generated by removing the ``get``, ``set``,
- ``has``, ``is``, ``add`` or ``remove`` prefix from the method name and
+ ``has``, ``is``, ``can``, ``add`` or ``remove`` prefix from the method name and
transforming the first letter to lowercase (e.g. ``getFirstName()`` ->
``firstName``).
+ .. versionadded:: 7.4
+
+ Support for the ``can`` prefix was introduced in Symfony 7.4.
+
During denormalization, it supports using the constructor as well as
the discovered methods.
diff --git a/serializer/encoders.rst b/serializer/encoders.rst
index 3d75d9073cf..a778ecf5c16 100644
--- a/serializer/encoders.rst
+++ b/serializer/encoders.rst
@@ -205,8 +205,15 @@ These are the options available on the :ref:`serializer context &]/``)
A regular expression pattern to determine if a value should be wrapped
in a CDATA section.
+``cdata_wrapping_name_pattern`` (default: ``false``)
+ A regular expression pattern that defines the names of fields whose values
+ should always be wrapped in a CDATA section, even if their contents don't
+ require it. Example: ``'/(firstname|lastname)/'``
``ignore_empty_attributes`` (default: ``false``)
If set to true, ignores all attributes with empty values in the generated XML
+``preserve_numeric_keys`` (default: ``false``)
+ If set to true, it keeps numeric array indexes (e.g. ``- ``)
+ instead of collapsing them into ``
- `` nodes.
.. versionadded:: 7.1
@@ -216,6 +223,11 @@ These are the options available on the :ref:`serializer context 2019-10-24
//
+Example with ``preserve_numeric_keys``::
+
+ use Symfony\Component\Serializer\Encoder\XmlEncoder;
+
+ $data = [
+ 'person' => [
+ ['firstname' => 'Benjamin', 'lastname' => 'Alexandre'],
+ ['firstname' => 'Damien', 'lastname' => 'Clay'],
+ ],
+ ];
+
+ $xmlEncoder->encode($data, 'xml', ['preserve_numeric_keys' => false]);
+ // outputs:
+ //
+ //
+ // Benjamin
+ // Alexandre
+ //
+ //
+ // Damien
+ // Clay
+ //
+ //
+
+ $xmlEncoder->encode($data, 'xml', ['preserve_numeric_keys' => true]);
+ // outputs:
+ //
+ //
+ //
-
+ // Benjamin
+ // Alexandre
+ //
+ // -
+ // Damien
+ // Clay
+ //
+ //
+ //
+
The ``YamlEncoder``
-------------------
diff --git a/serializer/streaming_json.rst b/serializer/streaming_json.rst
index 3fd44824bc6..60870f8a138 100644
--- a/serializer/streaming_json.rst
+++ b/serializer/streaming_json.rst
@@ -3,8 +3,7 @@ Streaming JSON
.. versionadded:: 7.3
- The JsonStreamer component was introduced in Symfony 7.3 as an
- :doc:`experimental feature `.
+ The JsonStreamer component was introduced in Symfony 7.3.
Symfony can encode PHP data structures to JSON streams and decode JSON streams
back into PHP data structures.
@@ -541,6 +540,16 @@ When callables are not enough, you can use a service implementing the
The ``getStreamValueType()`` method must return the value's type as it will
appear in the JSON stream.
+.. tip::
+
+ The ``$options`` argument of the ``transform()`` method includes a special
+ option called ``_current_object`` which gives access to the object holding
+ the current property (or ``null`` if there's none).
+
+ .. versionadded:: 7.4
+
+ The ``_current_object`` option was introduced in Symfony 7.4.
+
To use this transformer in a class, configure the ``#[ValueTransformer]`` attribute::
// src/Dto/Dog.php
diff --git a/service_container.rst b/service_container.rst
index 8b86d06a833..ed621943c1a 100644
--- a/service_container.rst
+++ b/service_container.rst
@@ -704,8 +704,7 @@ all their types (string, boolean, array, binary and PHP constant parameters).
However, there is another type of parameter related to services. In YAML config,
any string which starts with ``@`` is considered as the ID of a service, instead
-of a regular string. In XML config, use the ``type="service"`` type for the
-parameter and in PHP config use the ``service()`` function:
+of a regular string. In PHP config use the ``service()`` function:
.. configuration-block::
diff --git a/service_container/service_decoration.rst b/service_container/service_decoration.rst
index 7b5e8edbcc2..962d21249a9 100644
--- a/service_container/service_decoration.rst
+++ b/service_container/service_decoration.rst
@@ -123,6 +123,16 @@ but keeps a reference of the old one as ``.inner``:
->decorate(Mailer::class);
};
+.. tip::
+
+ You can apply multiple ``#[AsDecorator]`` attributes to the same class to
+ decorate multiple services with it.
+
+ .. versionadded:: 7.4
+
+ The possibility to allow multiple ``#[AsDecorator]`` attributes was
+ introduced in Symfony 7.4.
+
The ``decorates`` option tells the container that the ``App\DecoratingMailer``
service replaces the ``App\Mailer`` service. If you're using the
:ref:`default services.yaml configuration `,
diff --git a/setup.rst b/setup.rst
index a7fa8c66826..81ec61cfa1a 100644
--- a/setup.rst
+++ b/setup.rst
@@ -48,10 +48,10 @@ application:
.. code-block:: terminal
# run this if you are building a traditional web application
- $ symfony new my_project_directory --version="7.3.x" --webapp
+ $ symfony new my_project_directory --version="7.4.x-dev" --webapp
# run this if you are building a microservice, console application or API
- $ symfony new my_project_directory --version="7.3.x"
+ $ symfony new my_project_directory --version="7.4.x-dev"
The only difference between these two commands is the number of packages
installed by default. The ``--webapp`` option installs extra packages to give
@@ -63,12 +63,12 @@ Symfony application using Composer:
.. code-block:: terminal
# run this if you are building a traditional web application
- $ composer create-project symfony/skeleton:"7.3.x" my_project_directory
+ $ composer create-project symfony/skeleton:"7.4.x-dev" my_project_directory
$ cd my_project_directory
$ composer require webapp
# run this if you are building a microservice, console application or API
- $ composer create-project symfony/skeleton:"7.3.x" my_project_directory
+ $ composer create-project symfony/skeleton:"7.4.x-dev" my_project_directory
No matter which command you run to create the Symfony application. All of them
will create a new ``my_project_directory/`` directory, download some dependencies
diff --git a/setup/bundles.rst b/setup/bundles.rst
index 3747cd88a0a..62d7ffa8db1 100644
--- a/setup/bundles.rst
+++ b/setup/bundles.rst
@@ -86,6 +86,12 @@ given version, check for that feature rather than the kernel version::
// code for the new OptionsResolver API
}
+.. deprecated:: 7.4
+
+ Symfony 7.4 deprecated the XML configuration format, which was the recommended
+ format for bundles in previous versions. Consider using the `gromnan/symfony-config-xml-to-php`_
+ tool to automatically convert XML configuration files to PHP.
+
Testing your Bundle in Symfony Applications
-------------------------------------------
@@ -156,3 +162,4 @@ as a starting point for your own GitHub CI configuration:
.. _`Symfony releases plan`: https://symfony.com/releases
.. _`Composer path repository option`: https://getcomposer.org/doc/05-repositories.md#path
+.. _`gromnan/symfony-config-xml-to-php`: https://github.com/GromNaN/symfony-config-xml-to-php
diff --git a/testing.rst b/testing.rst
index a0f8b1c6a18..e92ecc0d098 100644
--- a/testing.rst
+++ b/testing.rst
@@ -994,11 +994,11 @@ However, Symfony provides useful shortcut methods for the most common cases:
Response Assertions
...................
-``assertResponseIsSuccessful(string $message = '', bool $verbose = true)``
+``assertResponseIsSuccessful(string $message = '', ?bool $verbose = null)``
Asserts that the response was successful (HTTP status is 2xx).
-``assertResponseStatusCodeSame(int $expectedCode, string $message = '', bool $verbose = true)``
+``assertResponseStatusCodeSame(int $expectedCode, string $message = '', ?bool $verbose = null)``
Asserts a specific HTTP status code.
-``assertResponseRedirects(?string $expectedLocation = null, ?int $expectedCode = null, string $message = '', bool $verbose = true)``
+``assertResponseRedirects(?string $expectedLocation = null, ?int $expectedCode = null, string $message = '', ?bool $verbose = null)``
Asserts the response is a redirect response (optionally, you can check
the target location and status code). The excepted location can be either
an absolute or a relative path.
@@ -1016,13 +1016,25 @@ Response Assertions
Asserts the response format returned by the
:method:`Symfony\\Component\\HttpFoundation\\Response::getFormat` method
is the same as the expected value.
-``assertResponseIsUnprocessable(string $message = '', bool $verbose = true)``
+``assertResponseIsUnprocessable(string $message = '', bool ?$verbose = null)``
Asserts the response is unprocessable (HTTP status is 422)
+By default, these assert methods provide detailed error messages when they fail.
+You can control the verbosity level using the optional ``verbose`` argument in
+each assert method. To set this verbosity level globally, use the
+``setBrowserKitAssertionsAsVerbose()`` method from the
+:class:`Symfony\\Bundle\\FrameworkBundle\\Test\\BrowserKitAssertionsTrait`::
+
+ BrowserKitAssertionsTrait::setBrowserKitAssertionsAsVerbose(false);
+
.. versionadded:: 7.1
The ``$verbose`` parameters were introduced in Symfony 7.1.
+.. versionadded:: 7.4
+
+ The ``setBrowserKitAssertionsAsVerbose()`` method was introduced in Symfony 7.4.
+
Request Assertions
..................
@@ -1041,6 +1053,10 @@ Browser Assertions
``assertBrowserCookieValueSame(string $name, string $expectedValue, string $path = '/', ?string $domain = null, string $message = '')``
Asserts the given cookie in the test Client is set to the expected
value.
+``assertBrowserHistoryIsOnFirstPage(string $message = '')``/``assertBrowserHistoryIsNotOnFirstPage(string $message = '')``
+ Asserts that the browser history is (not) on the first page.
+``assertBrowserHistoryIsOnLastPage(string $message = '')``/``assertBrowserHistoryIsNotOnLastPage(string $message = '')``
+ Asserts that the browser history is (not) on the last page.
``assertThatForClient(Constraint $constraint, string $message = '')``
Asserts the given Constraint in the Client. Useful for using your custom asserts
in the same way as built-in asserts (i.e. without passing the Client as argument)::
@@ -1051,6 +1067,10 @@ Browser Assertions
self::assertThatForClient(new SomeCustomConstraint());
}
+.. versionadded:: 7.4
+
+ The ``assertBrowserHistoryIsOnFirstPage()`` and ``assertBrowserHistoryIsOnLastPage()`` assertions were introduced in Symfony 7.4.
+
Crawler Assertions
..................
@@ -1113,14 +1133,18 @@ Mailer Assertions
``assertEmailHeaderSame(RawMessage $email, string $headerName, string $expectedValue, string $message = '')``/``assertEmailHeaderNotSame(RawMessage $email, string $headerName, string $expectedValue, string $message = '')``
Asserts that the given email does (not) have the expected header set to
the expected value.
-``assertEmailAddressContains(RawMessage $email, string $headerName, string $expectedValue, string $message = '')``
- Asserts that the given address header equals the expected e-mail
+``assertEmailAddressContains(RawMessage $email, string $headerName, string $expectedValue, string $message = '')``/``assertEmailAddressNotContains(RawMessage $email, string $headerName, string $expectedValue, string $message = '')``
+ Asserts that the given address header does (not) equal the expected e-mail
address. This assertion normalizes addresses like ``Jane Smith
`` into ``jane@example.com``.
``assertEmailSubjectContains(RawMessage $email, string $expectedValue, string $message = '')``/``assertEmailSubjectNotContains(RawMessage $email, string $expectedValue, string $message = '')``
Asserts that the subject of the given email does (not) contain the
expected subject.
+.. versionadded:: 7.4
+
+ The ``assertEmailAddressNotContains()`` assertion was introduced in Symfony 7.4.
+
Notifier Assertions
...................
diff --git a/translation.rst b/translation.rst
index 47f9124a5f2..6bffb28f3ce 100644
--- a/translation.rst
+++ b/translation.rst
@@ -343,6 +343,25 @@ Templates are now much simpler because you can pass translatable objects to the
There's also a :ref:`function called t() `,
available both in Twig and PHP, as a shortcut to create translatable objects.
+Non-Translatable Messages
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+In some cases, you may want to explicitly prevent a message from being
+translated. You can ensure this behavior by using the
+:class:`Symfony\\Component\\Translation\\StaticMessage` class::
+
+ use Symfony\Component\Translation\StaticMessage;
+
+ $message = new StaticMessage('This message will never be translated.');
+
+This can be useful when rendering user-defined content or other strings
+that must remain exactly as given.
+
+.. versionadded:: 7.4
+
+ The :class:`Symfony\\Component\\Translation\\StaticMessage` class was
+ introduced in Symfony 7.4.
+
.. _translation-in-templates:
Translations in Templates
diff --git a/validation/raw_values.rst b/validation/raw_values.rst
index 9c900ff2b36..4fecb7c44ee 100644
--- a/validation/raw_values.rst
+++ b/validation/raw_values.rst
@@ -75,7 +75,7 @@ Validation of arrays is possible using the ``Collection`` constraint::
]),
'email' => new Assert\Email(),
'simple' => new Assert\Length(['min' => 102]),
- 'eye_color' => new Assert\Choice([3, 4]),
+ 'eye_color' => new Assert\Choice(choices: [3, 4]),
'file' => new Assert\File(),
'password' => new Assert\Length(['min' => 60]),
'tags' => new Assert\Optional([
diff --git a/web_link.rst b/web_link.rst
index 2f2f96d106b..75563ef765f 100644
--- a/web_link.rst
+++ b/web_link.rst
@@ -201,6 +201,30 @@ You can also add links to the HTTP response directly from controllers and servic
are also defined as constants in the :class:`Symfony\\Component\\WebLink\\Link`
class (e.g. ``Link::REL_PRELOAD``, ``Link::REL_PRECONNECT``, etc.).
+Parsing Link Headers
+--------------------
+
+Some third-party APIs provide resources such as pagination URLs using the
+``Link`` HTTP header. The WebLink component provides the
+:class:`Symfony\\Component\\WebLink\\HttpHeaderParser` utility class to parse
+those headers and transform them into :class:`Symfony\\Component\\WebLink\\Link`
+instances::
+
+ use Symfony\Component\WebLink\HttpHeaderParser;
+
+ $parser = new HttpHeaderParser();
+ // get the value of the Link header from the Request
+ $linkHeader = '; rel="prerender",; rel="dns-prefetch"; pr="0.7",; rel="preload"; as="script"';
+
+ $links = $parser->parse($linkHeader)->getLinks();
+ $links[0]->getRels(); // ['prerender']
+ $links[1]->getAttributes(); // ['pr' => '0.7']
+ $links[2]->getHref(); // '/baz.js'
+
+.. versionadded:: 7.4
+
+ The ``HttpHeaderParser`` class was introduced in Symfony 7.4.
+
.. _`WebLink`: https://github.com/symfony/web-link
.. _`HTTP/2 Server Push`: https://tools.ietf.org/html/rfc7540#section-8.2
.. _`Resource Hints`: https://www.w3.org/TR/resource-hints/
diff --git a/webhook.rst b/webhook.rst
index d27a6e6d906..a12a82ffc76 100644
--- a/webhook.rst
+++ b/webhook.rst
@@ -175,12 +175,17 @@ Currently, the following third-party SMS transports support webhooks:
============ ==========================================
SMS service Parser service name
============ ==========================================
-Twilio ``notifier.webhook.request_parser.twilio``
+LOX24 ``notifier.webhook.request_parser.lox24``
Smsbox ``notifier.webhook.request_parser.smsbox``
Sweego ``notifier.webhook.request_parser.sweego``
+Twilio ``notifier.webhook.request_parser.twilio``
Vonage ``notifier.webhook.request_parser.vonage``
============ ==========================================
+.. versionadded:: 7.4
+
+ The support for ``LOX24`` was introduced in Symfony 7.4.
+
For SMS webhooks, react to the
:class:`Symfony\\Component\\RemoteEvent\\Event\\Sms\\SmsEvent` event::
diff --git a/workflow.rst b/workflow.rst
index 2a3d1d908ef..0664101f5fc 100644
--- a/workflow.rst
+++ b/workflow.rst
@@ -294,6 +294,441 @@ what actions are allowed on a blog post::
// See a specific available transition for the post in the current state
$transition = $workflow->getEnabledTransition($post, 'publish');
+Using Enums as Workflow Places
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+When using a state machine, you can use PHP backend enums as places in your workflows:
+
+.. versionadded:: 7.4
+
+ The support for PHP backed enums as workflow places was introduced with Symfony 7.4.
+
+First, define your enum with backed values::
+
+ // src/Enumeration/BlogPostStatus.php
+ namespace App\Enumeration;
+
+ enum BlogPostStatus: string
+ {
+ case Draft = 'draft';
+ case Reviewed = 'reviewed';
+ case Published = 'published';
+ case Rejected = 'rejected';
+ }
+
+Then configure the workflow using the enum cases as places, initial marking,
+and transitions:
+
+.. configuration-block::
+
+ .. code-block:: yaml
+
+ # config/packages/workflow.yaml
+ framework:
+ workflows:
+ blog_publishing:
+ type: 'workflow'
+ marking_store:
+ type: 'method'
+ property: 'status'
+ supports:
+ - App\Entity\BlogPost
+ initial_marking: !php/enum App\Enumeration\BlogPostStatus::Draft
+ places: !php/enum App\Enumeration\BlogPostStatus
+ transitions:
+ to_review:
+ from: !php/enum App\Enumeration\BlogPostStatus::Draft
+ to: !php/enum App\Enumeration\BlogPostStatus::Reviewed
+ publish:
+ from: !php/enum App\Enumeration\BlogPostStatus::Reviewed
+ to: !php/enum App\Enumeration\BlogPostStatus::Published
+ reject:
+ from: !php/enum App\Enumeration\BlogPostStatus::Reviewed
+ to: !php/enum App\Enumeration\BlogPostStatus::Rejected
+
+ .. code-block:: xml
+
+
+
+
+
+
+
+
+
+ status
+
+ App\Entity\BlogPost
+ draft
+
+
+ draft
+ reviewed
+
+
+ reviewed
+ published
+
+
+ reviewed
+ rejected
+
+
+
+
+
+ .. code-block:: php
+
+ // config/packages/workflow.php
+ use App\Entity\BlogPost;
+ use App\Enumeration\BlogPostStatus;
+ use Symfony\Config\FrameworkConfig;
+
+ return static function (FrameworkConfig $framework): void {
+ $blogPublishing = $framework->workflows()->workflows('blog_publishing');
+ $blogPublishing
+ ->type('workflow')
+ ->supports([BlogPost::class])
+ ->initialMarking([BlogPostStatus::Draft]);
+
+ $blogPublishing->markingStore()
+ ->type('method')
+ ->property('status');
+
+ $blogPublishing->places(BlogPostStatus::cases());
+
+ $blogPublishing->transition()
+ ->name('to_review')
+ ->from(BlogPostStatus::Draft)
+ ->to([BlogPostStatus::Reviewed]);
+
+ $blogPublishing->transition()
+ ->name('publish')
+ ->from([BlogPostStatus::Reviewed])
+ ->to([BlogPostStatus::Published]);
+
+ $blogPublishing->transition()
+ ->name('reject')
+ ->from([BlogPostStatus::Reviewed])
+ ->to([BlogPostStatus::Rejected]);
+ };
+
+The component will now transparently cast the enum to its backing value
+when needed and vice-versa when working with your objects::
+
+ // src/Entity/BlogPost.php
+ namespace App\Entity;
+
+ class BlogPost
+ {
+ private BlogPostStatus $status;
+
+ public function getStatus(): BlogPostStatus
+ {
+ return $this->status;
+ }
+
+ public function setStatus(BlogPostStatus $status): void
+ {
+ $this->status = $status;
+ }
+ }
+
+.. tip::
+
+ You can also use `glob patterns`_ of PHP constants and enums to list the places:
+
+ .. configuration-block::
+
+ .. code-block:: yaml
+
+ # config/packages/workflow.yaml
+ framework:
+ workflows:
+ my_workflow_name:
+ # with constants:
+ places: 'App\Workflow\MyWorkflow::PLACE_*'
+
+ # with enums:
+ places: !php/enum App\Workflow\Places
+
+ # ...
+
+ .. code-block:: xml
+
+
+
+
+
+
+
+ places="App\Workflow\MyWorkflow::PLACE_*"
+
+ places="App\Enumeration\BlogPostStatus::*">
+
+
+
+
+
+ .. code-block:: php
+
+ // config/packages/workflow.php
+ use App\Entity\BlogPost;
+ use App\Enumeration\BlogPostStatus;
+ use Symfony\Config\FrameworkConfig;
+
+ return static function (FrameworkConfig $framework): void {
+ $blogPublishing = $framework->workflows()->workflows('my_workflow_name');
+
+ // with constants:
+ $blogPublishing->places('App\Workflow\MyWorkflow::PLACE_*');
+
+ // with enums:
+ $blogPublishing->places(BlogPostStatus::cases());
+
+ // ...
+ };
+
+Using Weighted Transitions
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 7.4
+
+ Support for weighted transitions was introduced in Symfony 7.4.
+
+A key feature of workflows (as opposed to state machines) is that an object can
+be in multiple places simultaneously. For example, when building a product, you
+might assemble several components in parallel. However, in the previous example,
+each place could only record whether the object was there or not, like a binary flag.
+
+**Weighted transitions** introduce multiplicity: a place can now track how many
+times an object is in that place. Technically, weighted transitions allow you to
+define transitions where multiple tokens (instances) are consumed from or produced
+to places. This is useful for modeling complex workflows such as manufacturing
+processes, resource allocation, or any scenario where multiple instances of something
+need to be produced or consumed.
+
+For example, imagine a table-making workflow where you need to create 4 legs, 1 top,
+and track the process with a stopwatch. You can use weighted transitions to model this:
+
+.. configuration-block::
+
+ .. code-block:: yaml
+
+ # config/packages/workflow.yaml
+ framework:
+ workflows:
+ make_table:
+ type: 'workflow'
+ marking_store:
+ type: 'method'
+ property: 'marking'
+ supports:
+ - App\Entity\TableProject
+ initial_marking: init
+ places:
+ - init
+ - prepare_leg
+ - prepare_top
+ - stopwatch_running
+ - leg_created
+ - top_created
+ - finished
+ transitions:
+ start:
+ from: init
+ to:
+ - place: prepare_leg
+ weight: 4
+ - place: prepare_top
+ weight: 1
+ - place: stopwatch_running
+ weight: 1
+ build_leg:
+ from: prepare_leg
+ to: leg_created
+ build_top:
+ from: prepare_top
+ to: top_created
+ join:
+ from:
+ - place: leg_created
+ weight: 4
+ - top_created # weight defaults to 1
+ - stopwatch_running
+ to: finished
+
+ .. code-block:: xml
+
+
+
+
+
+
+
+
+ marking
+
+ App\Entity\TableProject
+ init
+
+ init
+ prepare_leg
+ prepare_top
+ stopwatch_running
+ leg_created
+ top_created
+ finished
+
+
+ init
+ prepare_leg
+ prepare_top
+ stopwatch_running
+
+
+ prepare_leg
+ leg_created
+
+
+ prepare_top
+ top_created
+
+
+ leg_created
+ top_created
+ stopwatch_running
+ finished
+
+
+
+
+
+ .. code-block:: php
+
+ // config/packages/workflow.php
+ use App\Entity\TableProject;
+ use Symfony\Config\FrameworkConfig;
+
+ return static function (FrameworkConfig $framework): void {
+ $makeTable = $framework->workflows()->workflows('make_table');
+ $makeTable
+ ->type('workflow')
+ ->supports([TableProject::class])
+ ->initialMarking(['init']);
+
+ $makeTable->markingStore()
+ ->type('method')
+ ->property('marking');
+
+ $makeTable->place()->name('init');
+ $makeTable->place()->name('prepare_leg');
+ $makeTable->place()->name('prepare_top');
+ $makeTable->place()->name('stopwatch_running');
+ $makeTable->place()->name('leg_created');
+ $makeTable->place()->name('top_created');
+ $makeTable->place()->name('finished');
+
+ $makeTable->transition()
+ ->name('start')
+ ->from(['init'])
+ ->to([
+ ['place' => 'prepare_leg', 'weight' => 4],
+ ['place' => 'prepare_top', 'weight' => 1],
+ ['place' => 'stopwatch_running', 'weight' => 1],
+ ]);
+
+ $makeTable->transition()
+ ->name('build_leg')
+ ->from(['prepare_leg'])
+ ->to(['leg_created']);
+
+ $makeTable->transition()
+ ->name('build_top')
+ ->from(['prepare_top'])
+ ->to(['top_created']);
+
+ $makeTable->transition()
+ ->name('join')
+ ->from([
+ ['place' => 'leg_created', 'weight' => 4],
+ 'top_created', // weight defaults to 1
+ 'stopwatch_running',
+ ])
+ ->to(['finished']);
+ };
+
+In this example, when the ``start`` transition is applied, it creates 4 tokens in
+the ``prepare_leg`` place, 1 token in ``prepare_top``, and 1 token in
+``stopwatch_running``. Then, the ``build_leg`` transition must be applied 4 times
+(once for each token), and the ``build_top`` transition once. Finally, the ``join``
+transition can only be applied when all 4 legs are created, the top is created,
+and the stopwatch is still running.
+
+Weighted transitions can also be defined programmatically using the
+:class:`Symfony\\Component\\Workflow\\Arc` class::
+
+ use Symfony\Component\Workflow\Arc;
+ use Symfony\Component\Workflow\Definition;
+ use Symfony\Component\Workflow\Transition;
+ use Symfony\Component\Workflow\Workflow;
+
+ $definition = new Definition(
+ ['init', 'prepare_leg', 'prepare_top', 'stopwatch_running', 'leg_created', 'top_created', 'finished'],
+ [
+ new Transition('start', 'init', [
+ new Arc('prepare_leg', 4),
+ new Arc('prepare_top', 1),
+ 'stopwatch_running', // defaults to weight 1
+ ]),
+ new Transition('build_leg', 'prepare_leg', 'leg_created'),
+ new Transition('build_top', 'prepare_top', 'top_created'),
+ new Transition('join', [
+ new Arc('leg_created', 4),
+ 'top_created',
+ 'stopwatch_running',
+ ], 'finished'),
+ ]
+ );
+
+ $workflow = new Workflow($definition);
+ $workflow->apply($subject, 'start');
+
+ // Build each leg (4 times)
+ $workflow->apply($subject, 'build_leg');
+ $workflow->apply($subject, 'build_leg');
+ $workflow->apply($subject, 'build_leg');
+ $workflow->apply($subject, 'build_leg');
+
+ // Build the top
+ $workflow->apply($subject, 'build_top');
+
+ // Now we can join all parts
+ $workflow->apply($subject, 'join');
+
+The ``Arc`` class takes two parameters: the place name and the weight (which must be
+greater than or equal to 1). When a place is specified as a simple string instead of
+an ``Arc`` object, it defaults to a weight of 1.
+
Using a multiple state marking store
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -1441,3 +1876,5 @@ Learn more
/workflow/workflow-and-state-machine
/workflow/dumping-workflows
+
+.. _`glob patterns`: https://php.net/glob