Repository: symfony/symfony
Pull request #60420: [WebLink] Add class to parse Link headers from HTTP responses
Author: login: GromNaN
Labels: name: Feature
name: Status: Reviewed
name: WebLink
Created: 2025-05-14T10:43:32Z | Merged: 2025-05-27T08:49:24Z

| Q             | A
| ------------- | ---
| Branch?       | 7.4
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | -
| License       | MIT

Some HTTP API expose a Link header for pagination (See [GitHub API](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2022-11-28#using-link-headers) or [Sentry API](https://docs.sentry.io/api/pagination/)), so it's necessary to parse this header to consume the API.

Since we already have a WebLink component, I think it's a good fit to add the logic for parsing the HTTP header into this component.

The existing packages use simplified pattern and does not support all the spec features:
- https://github.com/kelunik/link-header-rfc5988/blob/master/src/functions.php
- https://github.com/libgraviton/link-header-rel-parser/blob/develop/src/LinkHeader.php


Diff:
diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
index 912282f495dac..38cae0ae793f2 100644
--- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
+++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
@@ -216,6 +216,7 @@
 use Symfony\Component\Validator\ObjectInitializerInterface;
 use Symfony\Component\Validator\Validation;
 use Symfony\Component\Webhook\Controller\WebhookController;
+use Symfony\Component\WebLink\HttpHeaderParser;
 use Symfony\Component\WebLink\HttpHeaderSerializer;
 use Symfony\Component\Workflow;
 use Symfony\Component\Workflow\WorkflowInterface;
@@ -497,6 +498,11 @@ public function load(array $configs, ContainerBuilder $container): void
             }
 
             $loader->load('web_link.php');
+
+            // Require symfony/web-link 7.4
+            if (!class_exists(HttpHeaderParser::class)) {
+                $container->removeDefinition('web_link.http_header_parser');
+            }
         }
 
         if ($this->readConfigEnabled('uid', $container, $config['uid'])) {
diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web_link.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web_link.php
index 64345cc997717..df55d194734d5 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web_link.php
+++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web_link.php
@@ -12,6 +12,7 @@
 namespace Symfony\Component\DependencyInjection\Loader\Configurator;
 
 use Symfony\Component\WebLink\EventListener\AddLinkHeaderListener;
+use Symfony\Component\WebLink\HttpHeaderParser;
 use Symfony\Component\WebLink\HttpHeaderSerializer;
 
 return static function (ContainerConfigurator $container) {
@@ -20,6 +21,9 @@
         ->set('web_link.http_header_serializer', HttpHeaderSerializer::class)
         ->alias(HttpHeaderSerializer::class, 'web_link.http_header_serializer')
 
+        ->set('web_link.http_header_parser', HttpHeaderParser::class)
+        ->alias(HttpHeaderParser::class, 'web_link.http_header_parser')
+
         ->set('web_link.add_link_header_listener', AddLinkHeaderListener::class)
             ->args([
                 service('web_link.http_header_serializer'),
diff --git a/src/Symfony/Component/WebLink/CHANGELOG.md b/src/Symfony/Component/WebLink/CHANGELOG.md
index 28dad5abdd749..6da8115f91fcc 100644
--- a/src/Symfony/Component/WebLink/CHANGELOG.md
+++ b/src/Symfony/Component/WebLink/CHANGELOG.md
@@ -1,6 +1,12 @@
 CHANGELOG
 =========
 
+7.4
+---
+
+ * Add `HttpHeaderParser` to read `Link` headers from HTTP responses
+ * Make `HttpHeaderSerializer` non-final
+
 4.4.0
 -----
 
diff --git a/src/Symfony/Component/WebLink/HttpHeaderParser.php b/src/Symfony/Component/WebLink/HttpHeaderParser.php
new file mode 100644
index 0000000000000..15fc91cde2522
--- /dev/null
+++ b/src/Symfony/Component/WebLink/HttpHeaderParser.php
@@ -0,0 +1,87 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+use Psr\Link\EvolvableLinkProviderInterface;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): EvolvableLinkProviderInterface
+    {
+        if (is_array($headers)) {
+            $headers = implode(', ', $headers);
+        }
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headers, $matches, \PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $attributesString = $match[2];
+
+            $attributes = [];
+            if (preg_match_all(self::PARAM_PATTERN, $attributesString, $attributeMatches, \PREG_SET_ORDER)) {
+                $rels = null;
+                foreach ($attributeMatches as $pm) {
+                    $key = $pm[1];
+                    $value = match (true) {
+                        // Quoted value, unescape quotes
+                        ($pm[2] ?? '') !== '' => stripcslashes($pm[2]),
+                        ($pm[3] ?? '') !== '' => $pm[3],
+                        // No value
+                        default => true,
+                    };
+
+                    if ($key === 'rel') {
+                        // Only the first occurrence of the "rel" attribute is read
+                        $rels ??= $value === true ? [] : preg_split('/\s+/', $value, 0, \PREG_SPLIT_NO_EMPTY);
+                    } elseif (is_array($attributes[$key] ?? null)) {
+                        $attributes[$key][] = $value;
+                    } elseif (isset($attributes[$key])) {
+                        $attributes[$key] = [$attributes[$key], $value];
+                    } else {
+                        $attributes[$key] = $value;
+                    }
+                }
+            }
+
+            $link = new Link(null, $href);
+            foreach ($rels ?? [] as $rel) {
+                $link = $link->withRel($rel);
+            }
+            foreach ($attributes as $k => $v) {
+                $link = $link->withAttribute($k, $v);
+            }
+            $links = $links->withLink($link);
+        }
+
+        return $links;
+    }
+}
diff --git a/src/Symfony/Component/WebLink/HttpHeaderSerializer.php b/src/Symfony/Component/WebLink/HttpHeaderSerializer.php
index 4d537c96f9cb8..d3b686add0baa 100644
--- a/src/Symfony/Component/WebLink/HttpHeaderSerializer.php
+++ b/src/Symfony/Component/WebLink/HttpHeaderSerializer.php
@@ -20,7 +20,7 @@
  *
  * @author Kévin Dunglas <dunglas@gmail.com>
  */
-final class HttpHeaderSerializer
+class HttpHeaderSerializer
 {
     /**
      * Builds the value of the "Link" HTTP header.
diff --git a/src/Symfony/Component/WebLink/Link.php b/src/Symfony/Component/WebLink/Link.php
index 1f5fbbdf9c6b5..519194c675206 100644
--- a/src/Symfony/Component/WebLink/Link.php
+++ b/src/Symfony/Component/WebLink/Link.php
@@ -153,7 +153,7 @@ class Link implements EvolvableLinkInterface
     private array $rel = [];
 
     /**
-     * @var array<string, string|bool|string[]>
+     * @var array<string, scalar|\Stringable|list<scalar|\Stringable>>
      */
     private array $attributes = [];
 
@@ -181,6 +181,11 @@ public function getRels(): array
         return array_values($this->rel);
     }
 
+    /**
+     * Returns a list of attributes that describe the target URI.
+     *
+     * @return array<string, scalar|\Stringable|list<scalar|\Stringable>>
+     */
     public function getAttributes(): array
     {
         return $this->attributes;
@@ -210,6 +215,14 @@ public function withoutRel(string $rel): static
         return $that;
     }
 
+    /**
+     * Returns an instance with the specified attribute added.
+     *
+     * If the specified attribute is already present, it will be overwritten
+     * with the new value.
+     *
+     * @param scalar|\Stringable|list<scalar|\Stringable> $value
+     */
     public function withAttribute(string $attribute, string|\Stringable|int|float|bool|array $value): static
     {
         $that = clone $this;
diff --git a/src/Symfony/Component/WebLink/Tests/HttpHeaderParserTest.php b/src/Symfony/Component/WebLink/Tests/HttpHeaderParserTest.php
new file mode 100644
index 0000000000000..b2ccc3e89163a
--- /dev/null
+++ b/src/Symfony/Component/WebLink/Tests/HttpHeaderParserTest.php
@@ -0,0 +1,112 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink\Tests;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\WebLink\HttpHeaderParser;
+
+class HttpHeaderParserTest extends TestCase
+{
+    public function testParse()
+    {
+        $parser = new HttpHeaderParser();
+
+        $header = [
+            '</1>; rel="prerender",</2>; rel="dns-prefetch"; pr="0.7",</3>; rel="preload"; as="script"',
+            '</4>; rel="preload"; as="image"; nopush,</5>; rel="alternate next"; hreflang="fr"; hreflang="de"; title="Hello"'
+        ];
+        $provider = $parser->parse($header);
+        $links = $provider->getLinks();
+
+        self::assertCount(5, $links);
+
+        self::assertSame(['prerender'], $links[0]->getRels());
+        self::assertSame('/1', $links[0]->getHref());
+        self::assertSame([], $links[0]->getAttributes());
+
+        self::assertSame(['dns-prefetch'], $links[1]->getRels());
+        self::assertSame('/2', $links[1]->getHref());
+        self::assertSame(['pr' => '0.7'], $links[1]->getAttributes());
+
+        self::assertSame(['preload'], $links[2]->getRels());
+        self::assertSame('/3', $links[2]->getHref());
+        self::assertSame(['as' => 'script'], $links[2]->getAttributes());
+
+        self::assertSame(['preload'], $links[3]->getRels());
+        self::assertSame('/4', $links[3]->getHref());
+        self::assertSame(['as' => 'image', 'nopush' => true], $links[3]->getAttributes());
+
+        self::assertSame(['alternate', 'next'], $links[4]->getRels());
+        self::assertSame('/5', $links[4]->getHref());
+        self::assertSame(['hreflang' => ['fr', 'de'], 'title' => 'Hello'], $links[4]->getAttributes());
+    }
+
+    public function testParseEmpty()
+    {
+        $parser = new HttpHeaderParser();
+        $provider = $parser->parse('');
+        self::assertCount(0, $provider->getLinks());
+    }
+
+    /** @dataProvider provideHeaderParsingCases */
+    #[DataProvider('provideHeaderParsingCases')]
+    public function testParseVariousAttributes(string $header, array $expectedRels, array $expectedAttributes)
+    {
+        $parser = new HttpHeaderParser();
+        $links = $parser->parse($header)->getLinks();
+
+        self::assertCount(1, $links);
+        self::assertSame('/foo', $links[0]->getHref());
+        self::assertSame($expectedRels, $links[0]->getRels());
+        self::assertSame($expectedAttributes, $links[0]->getAttributes());
+    }
+
+    public static function provideHeaderParsingCases()
+    {
+        yield 'double_quotes_in_attribute_value' => [
+            '</foo>; rel="alternate"; title="\"escape me\" \"already escaped\" \"\"\""',
+            ['alternate'],
+            ['title' => '"escape me" "already escaped" """'],
+        ];
+
+        yield 'unquoted_attribute_value' => [
+            '</foo>; rel=alternate; type=text/html',
+            ['alternate'],
+            ['type' => 'text/html'],
+        ];
+
+        yield 'attribute_with_punctuation' => [
+            '</foo>; rel="alternate"; title=">; hello, world; test:case"',
+            ['alternate'],
+            ['title' => '>; hello, world; test:case'],
+        ];
+
+        yield 'no_rel' => [
+            '</foo>; type=text/html',
+            [],
+            ['type' => 'text/html'],
+        ];
+
+        yield 'empty_rel' => [
+            '</foo>; rel',
+            [],
+            [],
+        ];
+
+        yield 'multiple_rel_attributes_get_first' => [
+            '</foo>; rel="alternate" rel="next"',
+            ['alternate'],
+            [],
+        ];
+    }
+}
diff --git a/src/Symfony/Component/WebLink/Tests/LinkTest.php b/src/Symfony/Component/WebLink/Tests/LinkTest.php
index 226bc3af11620..07946af9b0d01 100644
--- a/src/Symfony/Component/WebLink/Tests/LinkTest.php
+++ b/src/Symfony/Component/WebLink/Tests/LinkTest.php
@@ -27,10 +27,10 @@ public function testCanSetAndRetrieveValues()
             ->withAttribute('me', 'you')
         ;
 
-        $this->assertEquals('http://www.google.com', $link->getHref());
+        $this->assertSame('http://www.google.com', $link->getHref());
         $this->assertContains('next', $link->getRels());
         $this->assertArrayHasKey('me', $link->getAttributes());
-        $this->assertEquals('you', $link->getAttributes()['me']);
+        $this->assertSame('you', $link->getAttributes()['me']);
     }
 
     public function testCanRemoveValues()
@@ -44,7 +44,7 @@ public function testCanRemoveValues()
         $link = $link->withoutAttribute('me')
             ->withoutRel('next');
 
-        $this->assertEquals('http://www.google.com', $link->getHref());
+        $this->assertSame('http://www.google.com', $link->getHref());
         $this->assertFalse(\in_array('next', $link->getRels(), true));
         $this->assertArrayNotHasKey('me', $link->getAttributes());
     }
@@ -65,7 +65,7 @@ public function testConstructor()
     {
         $link = new Link('next', 'http://www.google.com');
 
-        $this->assertEquals('http://www.google.com', $link->getHref());
+        $this->assertSame('http://www.google.com', $link->getHref());
         $this->assertContains('next', $link->getRels());
     }
 


Reviews:
user: login: OskarStark
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T11:15:50Z
user: login: OskarStark
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T11:16:05Z
user: login: OskarStark
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T11:17:08Z
user: login: OskarStark
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T11:17:21Z
user: login: OskarStark
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T11:17:28Z
user: login: dunglas
body: Nit 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-14T12:26:51Z
user: login: GromNaN
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T12:44:35Z
user: login: GromNaN
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T12:45:09Z
user: login: nicolas-grekas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T13:25:26Z
user: login: nicolas-grekas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T13:26:15Z
user: login: nicolas-grekas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T13:26:36Z
user: login: nicolas-grekas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T13:27:34Z
user: login: nicolas-grekas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T13:30:42Z
user: login: GromNaN
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T13:34:06Z
user: login: nicolas-grekas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T13:35:57Z
user: login: nicolas-grekas
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-14T13:52:31Z
user: login: GromNaN
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T13:52:57Z
user: login: OskarStark
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T14:04:54Z
user: login: GromNaN
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T16:32:54Z
user: login: alexandre-daubois
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T06:42:47Z
user: login: OskarStark
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T07:25:30Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T10:28:44Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T10:30:10Z
user: login: GromNaN
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T10:40:33Z
user: login: GromNaN
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T10:45:29Z
user: login: GromNaN
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T12:08:40Z
user: login: GromNaN
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T12:10:06Z
user: login: GromNaN
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T12:25:50Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T12:36:26Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T12:45:21Z
user: login: GromNaN
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T12:54:18Z
user: login: GromNaN
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T12:54:38Z
user: login: GromNaN
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T13:18:32Z

Review comments:
diff_hunk: @@ -1,6 +1,11 @@
 CHANGELOG
 =========
 
+7.4.0
+-----
path: src/Symfony/Component/WebLink/CHANGELOG.md
user: login: OskarStark
body: ```suggestion
7.4
---
```
created_at: 2025-05-14T11:15:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,6 +1,11 @@
 CHANGELOG
 =========
 
+7.4.0
+-----
+
+ * Add HttpHeaderParser to parse Link headers from HTTP responses
path: src/Symfony/Component/WebLink/CHANGELOG.md
user: login: OskarStark
body: ```suggestion
 * Add `HttpHeaderParser` to parse `Link` headers from HTTP responses
```
created_at: 2025-05-14T11:16:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,85 @@
+<?php
+
+namespace Symfony\Component\WebLink\Tests;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\WebLink\HttpHeaderParser;
+
+class HttpHeaderParserTest extends TestCase
+{
+    public function testParse()
+    {
+        $parser = new HttpHeaderParser();
+
+        $header = [
+            '</1>; rel="prerender",</2>; rel="dns-prefetch"; pr="0.7",</3>; rel="preload"; as="script"',
+            '</4>; rel="preload"; as="image"; nopush,</5>; rel="alternate next"; hreflang="fr"; hreflang="de"; title="Hello"'
+        ];
+        $provider = $parser->parse($header);
+        $links = $provider->getLinks();
+
+        $this->assertCount(5, $links);
+
+        $this->assertSame(['prerender'], $links[0]->getRels());
+        $this->assertSame('/1', $links[0]->getHref());
+        $this->assertSame(null, $links[0]->getAttribute('rel'));
+
+        $this->assertSame(['dns-prefetch'], $links[1]->getRels());
+        $this->assertSame('/2', $links[1]->getHref());
+        $this->assertSame('0.7', $links[1]->getAttribute('pr'));
+
+        $this->assertSame(['preload'], $links[2]->getRels());
+        $this->assertSame('/3', $links[2]->getHref());
+        $this->assertSame('script', $links[2]->getAttribute('as'));
+        $this->assertSame(null, $links[2]->getAttribute('nopush'));
+
+        $this->assertSame(['preload'], $links[3]->getRels());
+        $this->assertSame('/4', $links[3]->getHref());
+        $this->assertSame('image', $links[3]->getAttribute('as'));
+        $this->assertSame(true, $links[3]->getAttribute('nopush'));
+
+        $this->assertSame(['alternate', 'next'], $links[4]->getRels());
+        $this->assertSame('/5', $links[4]->getHref());
+        $this->assertSame(['fr', 'de'], $links[4]->getAttribute('hreflang'));
+        $this->assertSame('Hello', $links[4]->getAttribute('title'));
+    }
+
+    public function testParseEmpty()
+    {
+        $parser = new HttpHeaderParser();
+        $provider = $parser->parse('');
+        $this->assertCount(0, $provider->getLinks());
+    }
+
+    /**
+     * @dataProvider provideHeaderParsingCases
+     */
+    public function testParseVariousAttributes($header, $expectedAttributes)
path: src/Symfony/Component/WebLink/Tests/HttpHeaderParserTest.php
user: login: OskarStark
body: ```suggestion
    public function testParseVariousAttributes(string $header, array $expectedAttributes)
```
created_at: 2025-05-14T11:17:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,6 +31,7 @@ public function testCanSetAndRetrieveValues()
         $this->assertContains('next', $link->getRels());
         $this->assertArrayHasKey('me', $link->getAttributes());
         $this->assertEquals('you', $link->getAttributes()['me']);
+        $this->assertEquals('you', $link->getAttribute('me'));
path: src/Symfony/Component/WebLink/Tests/LinkTest.php
user: login: OskarStark
body: assertSame ?
created_at: 2025-05-14T11:17:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,6 +48,7 @@ public function testCanRemoveValues()
         $this->assertEquals('http://www.google.com', $link->getHref());
path: src/Symfony/Component/WebLink/Tests/LinkTest.php
user: login: OskarStark
body: assertSame ?
created_at: 2025-05-14T11:17:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -180,6 +180,11 @@ public function getAttributes(): array
         return $this->attributes;
     }
 
+    public function getAttribute(string $attribute): string|\Stringable|int|float|bool|array|null
path: src/Symfony/Component/WebLink/Link.php
user: login: stof
body: Please update the documentation of the `attributes` property to be consistent with the type defined in `withAttribute`.

It would also be great to document the type of arrays being supported, and to add the precise return type in `getAttributes`
created_at: 2025-05-14T12:05:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: stof
body: should we use the GenericLinkProvider implementation as return type (which is quite inflexible for future refactorings) or the PSR-13 LinkProviderInterface it implements ?
created_at: 2025-05-14T12:07:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
+    {
+        $headerString = is_array($headers) ? implode(',', $headers) : $headers;
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $paramsString = $match[2];
+
+            $params = [];
+            if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
+                foreach ($paramMatches as $pm) {
+                    $key = $pm[1];
+                    if (isset($pm[2]) && $pm[2] !== '') {
+                        // Quoted value, unescape quotes
+                        $value = stripcslashes($pm[2]);
+                    } elseif (isset($pm[3]) && $pm[3] !== '') {
+                        $value = $pm[3];
+                    } else {
+                        $value = true;
+                    }
+                    // Handle multiple attributes with same name
+                    if (isset($params[$key])) {
+                        if (!is_array($params[$key])) {
+                            $params[$key] = [$params[$key]];
+                        }
+                        $params[$key][] = $value;
+                    } else {
+                        $params[$key] = $value;
+                    }
+                }
+            }
+
+            if (!isset($params['rel'])) {
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: stof
body: what should be done if `rel` is an array because it is specified multiple times ?
created_at: 2025-05-14T12:12:31Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
+    {
+        $headerString = is_array($headers) ? implode(',', $headers) : $headers;
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $paramsString = $match[2];
+
+            $params = [];
+            if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
+                foreach ($paramMatches as $pm) {
+                    $key = $pm[1];
+                    if (isset($pm[2]) && $pm[2] !== '') {
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: dunglas
body: ```suggestion
                    if ($pm[2] ?? null !== '') {
```

created_at: 2025-05-14T12:24:47Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
+    {
+        $headerString = is_array($headers) ? implode(',', $headers) : $headers;
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $paramsString = $match[2];
+
+            $params = [];
+            if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
+                foreach ($paramMatches as $pm) {
+                    $key = $pm[1];
+                    if (isset($pm[2]) && $pm[2] !== '') {
+                        // Quoted value, unescape quotes
+                        $value = stripcslashes($pm[2]);
+                    } elseif (isset($pm[3]) && $pm[3] !== '') {
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: dunglas
body: ```suggestion
                    } elseif ($pm[3] ?? null !== '') {
```

created_at: 2025-05-14T12:25:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
+    {
+        $headerString = is_array($headers) ? implode(',', $headers) : $headers;
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $paramsString = $match[2];
+
+            $params = [];
+            if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
+                foreach ($paramMatches as $pm) {
+                    $key = $pm[1];
+                    if (isset($pm[2]) && $pm[2] !== '') {
+                        // Quoted value, unescape quotes
+                        $value = stripcslashes($pm[2]);
+                    } elseif (isset($pm[3]) && $pm[3] !== '') {
+                        $value = $pm[3];
+                    } else {
+                        $value = true;
+                    }
+                    // Handle multiple attributes with same name
+                    if (isset($params[$key])) {
+                        if (!is_array($params[$key])) {
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: dunglas
body: ```suggestion
                        if (!is_array($params[$key] ?? null)) {
```

created_at: 2025-05-14T12:26:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
+    {
+        $headerString = is_array($headers) ? implode(',', $headers) : $headers;
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $paramsString = $match[2];
+
+            $params = [];
+            if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
+                foreach ($paramMatches as $pm) {
+                    $key = $pm[1];
+                    if (isset($pm[2]) && $pm[2] !== '') {
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: GromNaN
body: Updated to `if (($pm[2] ?? '') !== '') {`, same for the next condition.
created_at: 2025-05-14T12:44:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
+    {
+        $headerString = is_array($headers) ? implode(',', $headers) : $headers;
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $paramsString = $match[2];
+
+            $params = [];
+            if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
+                foreach ($paramMatches as $pm) {
+                    $key = $pm[1];
+                    if (isset($pm[2]) && $pm[2] !== '') {
+                        // Quoted value, unescape quotes
+                        $value = stripcslashes($pm[2]);
+                    } elseif (isset($pm[3]) && $pm[3] !== '') {
+                        $value = $pm[3];
+                    } else {
+                        $value = true;
+                    }
+                    // Handle multiple attributes with same name
+                    if (isset($params[$key])) {
+                        if (!is_array($params[$key])) {
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: GromNaN
body: I did not find how to merge the `if`, but I could remove 1 nesting level.
created_at: 2025-05-14T12:45:08Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: nicolas-grekas
body: final + no base type = not SOLID
created_at: 2025-05-14T13:25:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
+    {
+        $headerString = is_array($headers) ? implode(',', $headers) : $headers;
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $paramsString = $match[2];
+
+            $params = [];
+            if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
+                foreach ($paramMatches as $pm) {
+                    $key = $pm[1];
+                    if (($pm[2] ?? '') !== '') {
+                        // Quoted value, unescape quotes
+                        $value = stripcslashes($pm[2]);
+                    } elseif (($pm[3] ?? '') !== '') {
+                        $value = $pm[3];
+                    } else {
+                        $value = true;
+                    }
+
+                    if (is_array($params[$key] ?? null)) {
+                        $params[$key][] = $value;
+                    } elseif (isset($params[$key])) {
+                        $params[$key] = [$params[$key], $value];
+                    } else {
+                        $params[$key] = $value;
+                    }
+                }
+            }
+
+            if (!isset($params['rel'])) {
+                continue;
+            }
+            $rels = preg_split('/\s+/', trim($params['rel']));
+            unset($params['rel']);
+
+            $link = new Link(array_shift($rels), $href);
+            foreach ($rels as $r) {
+                $link = $link->withRel($r);
+            }
+            foreach ($params as $k => $v) {
+                $link = $link->withAttribute($k, $v);
+            }
+            $links = $links->withLink($link);
+        }
+
+        return $links;
+    }
+}
+
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: nicolas-grekas
body: extra line I guess
created_at: 2025-05-14T13:26:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,85 @@
+<?php
+
+namespace Symfony\Component\WebLink\Tests;
path: src/Symfony/Component/WebLink/Tests/HttpHeaderParserTest.php
user: login: nicolas-grekas
body: missing license header
created_at: 2025-05-14T13:26:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
+    {
+        $headerString = is_array($headers) ? implode(',', $headers) : $headers;
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $paramsString = $match[2];
+
+            $params = [];
+            if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
+                foreach ($paramMatches as $pm) {
+                    $key = $pm[1];
+                    if (($pm[2] ?? '') !== '') {
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: nicolas-grekas
body: looks like a match statement would be even better ;)
created_at: 2025-05-14T13:27:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
+    {
+        $headerString = is_array($headers) ? implode(',', $headers) : $headers;
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $paramsString = $match[2];
+
+            $params = [];
+            if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
+                foreach ($paramMatches as $pm) {
+                    $key = $pm[1];
+                    if (($pm[2] ?? '') !== '') {
+                        // Quoted value, unescape quotes
+                        $value = stripcslashes($pm[2]);
+                    } elseif (($pm[3] ?? '') !== '') {
+                        $value = $pm[3];
+                    } else {
+                        $value = true;
+                    }
+
+                    if (is_array($params[$key] ?? null)) {
+                        $params[$key][] = $value;
+                    } elseif (isset($params[$key])) {
+                        $params[$key] = [$params[$key], $value];
+                    } else {
+                        $params[$key] = $value;
+                    }
+                }
+            }
+
+            if (!isset($params['rel'])) {
+                continue;
+            }
+            $rels = preg_split('/\s+/', trim($params['rel']));
+            unset($params['rel']);
+
+            $link = new Link(array_shift($rels), $href);
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: nicolas-grekas
body: I'd suggest starting with null instead of array_shift, it should be more performant
created_at: 2025-05-14T13:30:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: GromNaN
body: I blindly replicated the same thing as `HttpHeaderSerializer`. Let's remove the `final`.
created_at: 2025-05-14T13:31:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
+    {
+        $headerString = is_array($headers) ? implode(',', $headers) : $headers;
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $paramsString = $match[2];
+
+            $params = [];
+            if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
+                foreach ($paramMatches as $pm) {
+                    $key = $pm[1];
+                    if (($pm[2] ?? '') !== '') {
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: GromNaN
body: Indeed, done.
created_at: 2025-05-14T13:32:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,85 @@
+<?php
+
+namespace Symfony\Component\WebLink\Tests;
path: src/Symfony/Component/WebLink/Tests/HttpHeaderParserTest.php
user: login: GromNaN
body: Added
created_at: 2025-05-14T13:32:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: nicolas-grekas
body: We should remove final there also then.
BTW, did you consider a service definition to allow autowiring?
created_at: 2025-05-14T13:35:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: GromNaN
body: I removed the `final` from the other class and added framework integration.
created_at: 2025-05-14T13:52:56Z
author_association: MEMBER
diff_hunk: @@ -20,7 +20,7 @@
  *
  * @author Kévin Dunglas <dunglas@gmail.com>
  */
-final class HttpHeaderSerializer
path: src/Symfony/Component/WebLink/HttpHeaderSerializer.php
user: login: OskarStark
body: info in the changelog?
created_at: 2025-05-14T14:04:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,7 +20,7 @@
  *
  * @author Kévin Dunglas <dunglas@gmail.com>
  */
-final class HttpHeaderSerializer
path: src/Symfony/Component/WebLink/HttpHeaderSerializer.php
user: login: GromNaN
body: Ok
created_at: 2025-05-14T16:32:54Z
author_association: MEMBER
diff_hunk: @@ -180,6 +180,11 @@ public function getAttributes(): array
         return $this->attributes;
     }
 
+    public function getAttribute(string $attribute): string|\Stringable|int|float|bool|array|null
path: src/Symfony/Component/WebLink/Link.php
user: login: stof
body: maybe this should be a separate PR to merge it in 7.3 though, to provide the proper type sooner.
created_at: 2025-05-15T10:30:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: GromNaN
body: Good catch. The public methods of `GenericLinkProvider` are all in `EvolvableLinkProviderInterface`. I'll update the return type.
created_at: 2025-05-15T10:40:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
+    {
+        $headerString = is_array($headers) ? implode(',', $headers) : $headers;
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $paramsString = $match[2];
+
+            $params = [];
+            if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
+                foreach ($paramMatches as $pm) {
+                    $key = $pm[1];
+                    if (isset($pm[2]) && $pm[2] !== '') {
+                        // Quoted value, unescape quotes
+                        $value = stripcslashes($pm[2]);
+                    } elseif (isset($pm[3]) && $pm[3] !== '') {
+                        $value = $pm[3];
+                    } else {
+                        $value = true;
+                    }
+                    // Handle multiple attributes with same name
+                    if (isset($params[$key])) {
+                        if (!is_array($params[$key])) {
+                            $params[$key] = [$params[$key]];
+                        }
+                        $params[$key][] = $value;
+                    } else {
+                        $params[$key] = $value;
+                    }
+                }
+            }
+
+            if (!isset($params['rel'])) {
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: GromNaN
body: Good question. I'll take the first value according to the spec:

> **[5.3](https://datatracker.ietf.org/doc/html/rfc5988#section-5.3).  Relation Type**
>   The relation type of a link is conveyed in the "rel" parameter's value.  The "rel" parameter MUST NOT appear more than once in a given link-value; occurrences after the first MUST be ignored by parsers.

created_at: 2025-05-15T10:45:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
+    {
+        $headerString = is_array($headers) ? implode(',', $headers) : $headers;
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $paramsString = $match[2];
+
+            $params = [];
+            if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
+                foreach ($paramMatches as $pm) {
+                    $key = $pm[1];
+                    if (isset($pm[2]) && $pm[2] !== '') {
+                        // Quoted value, unescape quotes
+                        $value = stripcslashes($pm[2]);
+                    } elseif (isset($pm[3]) && $pm[3] !== '') {
+                        $value = $pm[3];
+                    } else {
+                        $value = true;
+                    }
+                    // Handle multiple attributes with same name
+                    if (isset($params[$key])) {
+                        if (!is_array($params[$key])) {
+                            $params[$key] = [$params[$key]];
+                        }
+                        $params[$key][] = $value;
+                    } else {
+                        $params[$key] = $value;
+                    }
+                }
+            }
+
+            if (!isset($params['rel'])) {
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: GromNaN
body: I wonder if we should still return links that doesn't have a `rel` attribute. They will never be returned by `getLinksByRel`, but give exhaustivity if the parser is used for tests.
created_at: 2025-05-15T12:08:40Z
author_association: MEMBER
diff_hunk: @@ -180,6 +180,11 @@ public function getAttributes(): array
         return $this->attributes;
     }
 
+    public function getAttribute(string $attribute): string|\Stringable|int|float|bool|array|null
+    {
+        return $this->attributes[$attribute] ?? null;
path: src/Symfony/Component/WebLink/Link.php
user: login: GromNaN
body: `false` could be returned instead of `null`. It means "attribute not set".
```suggestion
        return $this->attributes[$attribute] ?? false;
```
created_at: 2025-05-15T12:10:06Z
author_association: MEMBER
diff_hunk: @@ -180,6 +180,11 @@ public function getAttributes(): array
         return $this->attributes;
     }
 
+    public function getAttribute(string $attribute): string|\Stringable|int|float|bool|array|null
path: src/Symfony/Component/WebLink/Link.php
user: login: GromNaN
body: I'll remove this new method because it's not part of the interface. Accessing array value with null coalescence if fine.
created_at: 2025-05-15T12:25:50Z
author_association: MEMBER
diff_hunk: @@ -180,6 +180,11 @@ public function getAttributes(): array
         return $this->attributes;
     }
 
+    public function getAttribute(string $attribute): string|\Stringable|int|float|bool|array|null
+    {
+        return $this->attributes[$attribute] ?? null;
path: src/Symfony/Component/WebLink/Link.php
user: login: stof
body: I think `null` is a better representation of the absence of value.

Alternatively, we may avoid adding a method that is not part of the PSR-13 LinkInterface implemented by that class, to push consumer code to be written against the interoperable interface (i.e. using `getAttributes()['rel'] ?? null`, which let's _them_ be responsible of choosing the appropriate fallback for their case)
created_at: 2025-05-15T12:36:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: stof
body: Should we add an interface to provide autowiring based on the interface rather than the concrete implementation ?
created_at: 2025-05-15T12:45:21Z
author_association: MEMBER
diff_hunk: @@ -180,6 +180,11 @@ public function getAttributes(): array
         return $this->attributes;
     }
 
+    public function getAttribute(string $attribute): string|\Stringable|int|float|bool|array|null
+    {
+        return $this->attributes[$attribute] ?? null;
path: src/Symfony/Component/WebLink/Link.php
user: login: GromNaN
body: Method removed.
created_at: 2025-05-15T12:54:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
+{
+    // Regex to match each link entry: <...>; param1=...; param2=...
+    private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
+
+    // Regex to match parameters: ; key[=value]
+    private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
+
+    /**
+     * @param string|string[] $headers Value of the "Link" HTTP header
+     */
+    public function parse(string|array $headers): GenericLinkProvider
+    {
+        $headerString = is_array($headers) ? implode(',', $headers) : $headers;
+        $links = new GenericLinkProvider();
+
+        if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
+            return $links;
+        }
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+            $paramsString = $match[2];
+
+            $params = [];
+            if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
+                foreach ($paramMatches as $pm) {
+                    $key = $pm[1];
+                    if (isset($pm[2]) && $pm[2] !== '') {
+                        // Quoted value, unescape quotes
+                        $value = stripcslashes($pm[2]);
+                    } elseif (isset($pm[3]) && $pm[3] !== '') {
+                        $value = $pm[3];
+                    } else {
+                        $value = true;
+                    }
+                    // Handle multiple attributes with same name
+                    if (isset($params[$key])) {
+                        if (!is_array($params[$key])) {
+                            $params[$key] = [$params[$key]];
+                        }
+                        $params[$key][] = $value;
+                    } else {
+                        $params[$key] = $value;
+                    }
+                }
+            }
+
+            if (!isset($params['rel'])) {
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: GromNaN
body: Updated to return links without rel.
created_at: 2025-05-15T12:54:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\WebLink;
+
+/**
+ * Parse a list of HTTP Link headers into a list of Link instances.
+ *
+ * @see https://tools.ietf.org/html/rfc5988
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class HttpHeaderParser
path: src/Symfony/Component/WebLink/HttpHeaderParser.php
user: login: GromNaN
body: In absolute terms, that's what we should do. We would do the same for `HttpHeaderSerializer` then.
But I don't see why anyone would write a different implementation.
created_at: 2025-05-15T13:18:32Z
author_association: MEMBER

PR comments:
user: login: carsonbot
created_at: 2025-05-14T10:43:37Z
body: Hey!

Thanks for your PR. You are targeting branch "7.3" but it seems your PR description refers to branch "7.4".
Could you update the PR description or change target branch? This helps core maintainers a lot.

Cheers!

Carsonbot
author_association: NONE
user: login: GromNaN
created_at: 2025-05-14T11:39:31Z
body: Thanks for the review @OskarStark, I addressed all your comments.
author_association: MEMBER