From b791d9210ee96d9627d47b2130a5bd17302dd489 Mon Sep 17 00:00:00 2001
From: Kevin Bond <kevinbond@gmail.com>
Date: Sat, 29 Mar 2025 14:10:51 -0400
Subject: [PATCH 1/6] [RateLimiter] Add `RateLimiterBuilder`

---
 .../RateLimiter/RateLimiterBuilder.php        | 102 ++++++++++++++++++
 .../RateLimiter/RateLimiterFactory.php        |  16 +--
 2 files changed, 103 insertions(+), 15 deletions(-)
 create mode 100644 src/Symfony/Component/RateLimiter/RateLimiterBuilder.php

diff --git a/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php b/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php
new file mode 100644
index 0000000000000..fef16d0ce00e9
--- /dev/null
+++ b/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php
@@ -0,0 +1,102 @@
+<?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\RateLimiter;
+
+use Symfony\Component\Lock\LockInterface;
+use Symfony\Component\RateLimiter\Policy\FixedWindowLimiter;
+use Symfony\Component\RateLimiter\Policy\NoLimiter;
+use Symfony\Component\RateLimiter\Policy\Rate;
+use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter;
+use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter;
+use Symfony\Component\RateLimiter\Storage\StorageInterface;
+
+/**
+ * @author Kevin Bond <kevinbond@gmail.com>
+ */
+class RateLimiterBuilder
+{
+    public function __construct(
+        private StorageInterface $storage,
+        private ?LockInterface $lock = null,
+    ) {
+    }
+
+    /**
+     * @param string               $id       Unique identifier for the rate limiter
+     * @param int                  $limit    Maximum allowed hits for the passed interval
+     * @param \DateInterval|string $interval If string, must be a number followed by "second",
+     *                                       "minute", "hour", "day", "week" or "month" (or their
+     *                                       plural equivalent)
+     */
+    public function slidingWindow(string $id, int $limit, \DateInterval|string $interval): LimiterInterface
+    {
+        return new SlidingWindowLimiter($id, $limit, self::intervalNormalizer($interval), $this->storage, $this->lock);
+    }
+
+    /**
+     * @param string               $id       Unique identifier for the rate limiter
+     * @param int                  $limit    Maximum allowed hits for the passed interval
+     * @param \DateInterval|string $interval If string, must be a number followed by "second",
+     *                                       "minute", "hour", "day", "week" or "month" (or their
+     *                                       plural equivalent)
+     */
+    public function fixedWindow(string $id, int $limit, \DateInterval|string $interval): LimiterInterface
+    {
+        return new FixedWindowLimiter($id, $limit, self::intervalNormalizer($interval), $this->storage, $this->lock);
+    }
+
+    /**
+     * @param string $id       Unique identifier for the rate limiter
+     * @param int    $maxBurst The maximum allowed hits in a burst
+     * @param Rate   $rate     The rate at which tokens are added to the bucket
+     */
+    public function tokenBucket(string $id, int $maxBurst, Rate $rate): LimiterInterface
+    {
+        return new TokenBucketLimiter($id, $maxBurst, $rate, $this->storage, $this->lock);
+    }
+
+    public function compound(LimiterInterface ...$limiters): LimiterInterface
+    {
+        return new CompoundLimiter($limiters);
+    }
+
+    public function noop(): LimiterInterface
+    {
+        return new NoLimiter();
+    }
+
+    /**
+     * @internal
+     */
+    public static function intervalNormalizer(\DateInterval|string $interval): \DateInterval
+    {
+        if ($interval instanceof \DateInterval) {
+            return $interval;
+        }
+
+        // Create DateTimeImmutable from unix timestamp, so the default timezone is ignored and we don't need to
+        // deal with quirks happening when modifying dates using a timezone with DST.
+        $now = \DateTimeImmutable::createFromFormat('U', time());
+
+        try {
+            $nowPlusInterval = @$now->modify('+'.$interval);
+        } catch (\DateMalformedStringException $e) {
+            throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval), 0, $e);
+        }
+
+        if (!$nowPlusInterval) {
+            throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval));
+        }
+
+        return $now->diff($nowPlusInterval);
+    }
+}
diff --git a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php
index f9d0ca9a7386e..89026f01cca06 100644
--- a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php
+++ b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php
@@ -56,21 +56,7 @@ public function create(?string $key = null): LimiterInterface
     private static function configureOptions(OptionsResolver $options): void
     {
         $intervalNormalizer = static function (Options $options, string $interval): \DateInterval {
-            // Create DateTimeImmutable from unix timesatmp, so the default timezone is ignored and we don't need to
-            // deal with quirks happening when modifying dates using a timezone with DST.
-            $now = \DateTimeImmutable::createFromFormat('U', time());
-
-            try {
-                $nowPlusInterval = @$now->modify('+'.$interval);
-            } catch (\DateMalformedStringException $e) {
-                throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval), 0, $e);
-            }
-
-            if (!$nowPlusInterval) {
-                throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval));
-            }
-
-            return $now->diff($nowPlusInterval);
+            return RateLimiterBuilder::intervalNormalizer($interval);
         };
 
         $options

From 9fa23d52f7e088edb97f7de429fc5185d1d00fd6 Mon Sep 17 00:00:00 2001
From: Kevin Bond <kevinbond@gmail.com>
Date: Wed, 2 Apr 2025 17:31:26 -0400
Subject: [PATCH 2/6] frameworkbundle configuration/tests

---
 .../DependencyInjection/Configuration.php     | 18 +++++++++
 .../FrameworkExtension.php                    | 33 ++++++++++++++++
 .../Resources/config/rate_limiter.php         |  8 ++++
 .../PhpFrameworkExtensionTest.php             | 39 +++++++++++++++++++
 4 files changed, 98 insertions(+)

diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
index 6dc1b7d6e57d8..b54e8f7122b61 100644
--- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
+++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
@@ -2499,6 +2499,24 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode, callable $
                         })
                     ->end()
                     ->children()
+                        ->arrayNode('builder')
+                            ->info('Configuration for the RateLimiterBuilder service.')
+                            ->addDefaultsIfNotSet()
+                            ->children()
+                                ->scalarNode('lock_factory')
+                                    ->info('The service ID of the lock factory to use with the RateLimiterBuilder.')
+                                    ->defaultValue('auto')
+                                ->end()
+                                ->scalarNode('cache_pool')
+                                    ->info('The cache pool to use with RateLimiterBuilder.')
+                                    ->defaultValue('cache.rate_limiter')
+                                ->end()
+                                ->scalarNode('storage_service')
+                                    ->info('The service ID of a custom storage implementation, this precedes any configured "cache_pool".')
+                                    ->defaultNull()
+                                ->end()
+                            ->end()
+                        ->end()
                         ->arrayNode('limiters')
                             ->useAttributeAsKey('name')
                             ->arrayPrototype()
diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
index 98e2e8904c3f2..6c7d89502c94e 100644
--- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
+++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
@@ -159,6 +159,7 @@
 use Symfony\Component\PropertyInfo\PropertyListExtractorInterface;
 use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
 use Symfony\Component\RateLimiter\LimiterInterface;
+use Symfony\Component\RateLimiter\RateLimiterBuilder;
 use Symfony\Component\RateLimiter\RateLimiterFactory;
 use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
 use Symfony\Component\RateLimiter\Storage\CacheStorage;
@@ -3272,6 +3273,38 @@ private function registerRateLimiterConfiguration(array $config, ContainerBuilde
                 $container->registerAliasForArgument($limiterId, RateLimiterFactoryInterface::class, $name.'.limiter');
             }
         }
+
+        if (class_exists(RateLimiterBuilder::class)) {
+            $builderConfig = $config['builder'];
+
+            if ('auto' === $builderConfig['lock_factory']) {
+                $builderConfig['lock_factory'] = $this->isInitializedConfigEnabled('lock') ? 'lock.factory' : null;
+            }
+
+            $builder = $container->getDefinition('limiter_builder');
+
+            if (null === $storageId = $builderConfig['storage_service'] ?? null) {
+                $container->register($storageId = 'limiter_builder.storage', CacheStorage::class)->addArgument(new Reference($builderConfig['cache_pool']));
+            }
+
+            $builder->replaceArgument(0, new Reference($storageId));
+
+            if ($builderConfig['lock_factory']) {
+                if (!interface_exists(LockInterface::class)) {
+                    throw new LogicException('Rate Limiter Builder requires the Lock component to be installed. Try running "composer require symfony/lock".');
+                }
+
+                if (!$this->isInitializedConfigEnabled('lock')) {
+                    throw new LogicException('Rate Limiter Builder requires the Lock component to be configured.');
+                }
+
+                $builder->replaceArgument(1, new Reference($builderConfig['lock_factory']));
+            }
+
+            $container->setAlias(RateLimiterBuilder::class, 'limiter_builder');
+        } else {
+            $container->removeDefinition('limiter_builder');
+        }
     }
 
     private function registerUidConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void
diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php
index 727a1f6364456..bc3b01ebee9c4 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php
+++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php
@@ -11,7 +11,9 @@
 
 namespace Symfony\Component\DependencyInjection\Loader\Configurator;
 
+use Symfony\Component\RateLimiter\RateLimiterBuilder;
 use Symfony\Component\RateLimiter\RateLimiterFactory;
+use Symfony\Component\RateLimiter\Storage\CacheStorage;
 
 return static function (ContainerConfigurator $container) {
     $container->services()
@@ -26,5 +28,11 @@
                 abstract_arg('storage'),
                 null,
             ])
+
+        ->set('limiter_builder', RateLimiterBuilder::class)
+            ->args([
+                abstract_arg('storage'),
+                null,
+            ])
     ;
 };
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php
index ea8d481e0f0f0..269dcfeba0adc 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php
@@ -17,6 +17,7 @@
 use Symfony\Component\DependencyInjection\Exception\LogicException;
 use Symfony\Component\DependencyInjection\Exception\OutOfBoundsException;
 use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
+use Symfony\Component\RateLimiter\RateLimiterBuilder;
 use Symfony\Component\Workflow\Exception\InvalidDefinitionException;
 
 class PhpFrameworkExtensionTest extends FrameworkExtensionTestCase
@@ -290,4 +291,42 @@ public function testRateLimiterIsTagged()
         $this->assertSame('first', $container->getDefinition('limiter.first')->getTag('rate_limiter')[0]['name']);
         $this->assertSame('second', $container->getDefinition('limiter.second')->getTag('rate_limiter')[0]['name']);
     }
+
+    public function testRateLimiterBuilderDefault()
+    {
+        $container = $this->createContainerFromClosure(function (ContainerBuilder $container) {
+            $container->loadFromExtension('framework', [
+                'annotations' => false,
+                'http_method_override' => false,
+                'handle_all_throwables' => true,
+                'php_errors' => ['log' => true],
+                'rate_limiter' => true,
+            ]);
+        });
+
+        $this->assertSame('cache.rate_limiter', (string) $container->getDefinition('limiter_builder.storage')->getArgument(0));
+
+        $builder = $container->getDefinition('limiter_builder');
+        $this->assertSame('limiter_builder.storage', (string) $builder->getArgument(0));
+        $this->assertNull($builder->getArgument(1));
+
+        $this->assertSame('limiter_builder', (string) $container->getAlias(RateLimiterBuilder::class));
+    }
+
+    public function testRateLimiterBuilderDefaultWithLock()
+    {
+        $container = $this->createContainerFromClosure(function (ContainerBuilder $container) {
+            $container->loadFromExtension('framework', [
+                'annotations' => false,
+                'http_method_override' => false,
+                'handle_all_throwables' => true,
+                'php_errors' => ['log' => true],
+                'lock' => true,
+                'rate_limiter' => true,
+            ]);
+        });
+
+        $builder = $container->getDefinition('limiter_builder');
+        $this->assertSame('lock.factory', (string) $builder->getArgument(1));
+    }
 }

From 5f3bce58cb77f7f6016e792ae60651a70e9d9dda Mon Sep 17 00:00:00 2001
From: Kevin Bond <kevinbond@gmail.com>
Date: Wed, 2 Apr 2025 17:34:02 -0400
Subject: [PATCH 3/6] changelog

---
 src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md | 1 +
 src/Symfony/Component/RateLimiter/CHANGELOG.md  | 1 +
 2 files changed, 2 insertions(+)

diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
index b7efe5a18bbf7..38734c622ec00 100644
--- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
+++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
@@ -23,6 +23,7 @@ CHANGELOG
    the `#[AsController]` attribute is no longer required
  * Deprecate setting the `framework.profiler.collect_serializer_data` config option to `false`
  * Set `framework.rate_limiter.limiters.*.lock_factory` to `auto` by default
+ * Add `framework.rate_limiter.builder` option
 
 7.2
 ---
diff --git a/src/Symfony/Component/RateLimiter/CHANGELOG.md b/src/Symfony/Component/RateLimiter/CHANGELOG.md
index d2851b915d62e..adca15c601706 100644
--- a/src/Symfony/Component/RateLimiter/CHANGELOG.md
+++ b/src/Symfony/Component/RateLimiter/CHANGELOG.md
@@ -6,6 +6,7 @@ CHANGELOG
 
  * Add `RateLimiterFactoryInterface`
  * Add `CompoundRateLimiterFactory`
+ * Add `RateLimiterBuilder`
 
 6.4
 ---

From 224dbab91463208faab4e64d787c0f361b91113a Mon Sep 17 00:00:00 2001
From: Kevin Bond <kevinbond@gmail.com>
Date: Wed, 2 Apr 2025 19:42:32 -0400
Subject: [PATCH 4/6] test

---
 .../Tests/RateLimiterBuilderTest.php          | 37 +++++++++++++++++++
 1 file changed, 37 insertions(+)
 create mode 100644 src/Symfony/Component/RateLimiter/Tests/RateLimiterBuilderTest.php

diff --git a/src/Symfony/Component/RateLimiter/Tests/RateLimiterBuilderTest.php b/src/Symfony/Component/RateLimiter/Tests/RateLimiterBuilderTest.php
new file mode 100644
index 0000000000000..fe17b888939ff
--- /dev/null
+++ b/src/Symfony/Component/RateLimiter/Tests/RateLimiterBuilderTest.php
@@ -0,0 +1,37 @@
+<?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\RateLimiter\Tests;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\RateLimiter\CompoundLimiter;
+use Symfony\Component\RateLimiter\LimiterInterface;
+use Symfony\Component\RateLimiter\Policy\FixedWindowLimiter;
+use Symfony\Component\RateLimiter\Policy\NoLimiter;
+use Symfony\Component\RateLimiter\Policy\Rate;
+use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter;
+use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter;
+use Symfony\Component\RateLimiter\RateLimiterBuilder;
+use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
+
+class RateLimiterBuilderTest extends TestCase
+{
+    public function testCreateMethods()
+    {
+        $builder = new RateLimiterBuilder(new InMemoryStorage());
+
+        $this->assertInstanceOf(SlidingWindowLimiter::class, $builder->slidingWindow('foo', 5, '1 minute'));
+        $this->assertInstanceOf(FixedWindowLimiter::class, $builder->fixedWindow('foo', 5, '1 minute'));
+        $this->assertInstanceOf(TokenBucketLimiter::class, $builder->tokenBucket('foo', 5, Rate::perHour(2)));
+        $this->assertInstanceOf(CompoundLimiter::class, $builder->compound($this->createMock(LimiterInterface::class)));
+        $this->assertInstanceOf(NoLimiter::class, $builder->noop());
+    }
+}

From 84d055ec9f684caf140a30636de01983db02290f Mon Sep 17 00:00:00 2001
From: Kevin Bond <kevinbond@gmail.com>
Date: Fri, 4 Apr 2025 12:12:58 -0400
Subject: [PATCH 5/6] fix tests

---
 .../Tests/DependencyInjection/ConfigurationTest.php       | 5 +++++
 .../DependencyInjection/PhpFrameworkExtensionTest.php     | 8 ++++++++
 2 files changed, 13 insertions(+)

diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php
index c8142e98ab1a7..06731161aab08 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php
@@ -988,6 +988,11 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor
             'rate_limiter' => [
                 'enabled' => !class_exists(FullStack::class) && class_exists(TokenBucketLimiter::class),
                 'limiters' => [],
+                'builder' => [
+                    'lock_factory' => 'auto',
+                    'cache_pool' => 'cache.rate_limiter',
+                    'storage_service' => null,
+                ]
             ],
             'uid' => [
                 'enabled' => !class_exists(FullStack::class) && class_exists(UuidFactory::class),
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php
index 269dcfeba0adc..b2ab5ae6caf53 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php
@@ -294,6 +294,10 @@ public function testRateLimiterIsTagged()
 
     public function testRateLimiterBuilderDefault()
     {
+        if (!class_exists(RateLimiterBuilder::class)) {
+            $this->markTestSkipped('RateLimiterBuilder is not available.');
+        }
+
         $container = $this->createContainerFromClosure(function (ContainerBuilder $container) {
             $container->loadFromExtension('framework', [
                 'annotations' => false,
@@ -315,6 +319,10 @@ public function testRateLimiterBuilderDefault()
 
     public function testRateLimiterBuilderDefaultWithLock()
     {
+        if (!class_exists(RateLimiterBuilder::class)) {
+            $this->markTestSkipped('RateLimiterBuilder is not available.');
+        }
+
         $container = $this->createContainerFromClosure(function (ContainerBuilder $container) {
             $container->loadFromExtension('framework', [
                 'annotations' => false,

From eae6a0d205b5a43294e5aac94dd7d7bbdbb21d13 Mon Sep 17 00:00:00 2001
From: Kevin Bond <kevinbond@gmail.com>
Date: Mon, 7 Apr 2025 10:05:37 -0400
Subject: [PATCH 6/6] review

---
 .../PhpFrameworkExtensionTest.php             |  8 -----
 .../RateLimiter/RateLimiterBuilder.php        | 31 ++-----------------
 .../RateLimiter/RateLimiterFactory.php        |  3 +-
 .../Component/RateLimiter/Util/TimeUtil.php   | 23 ++++++++++++++
 4 files changed, 28 insertions(+), 37 deletions(-)

diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php
index b2ab5ae6caf53..5b5c63ca6c003 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php
@@ -300,10 +300,6 @@ public function testRateLimiterBuilderDefault()
 
         $container = $this->createContainerFromClosure(function (ContainerBuilder $container) {
             $container->loadFromExtension('framework', [
-                'annotations' => false,
-                'http_method_override' => false,
-                'handle_all_throwables' => true,
-                'php_errors' => ['log' => true],
                 'rate_limiter' => true,
             ]);
         });
@@ -325,10 +321,6 @@ public function testRateLimiterBuilderDefaultWithLock()
 
         $container = $this->createContainerFromClosure(function (ContainerBuilder $container) {
             $container->loadFromExtension('framework', [
-                'annotations' => false,
-                'http_method_override' => false,
-                'handle_all_throwables' => true,
-                'php_errors' => ['log' => true],
                 'lock' => true,
                 'rate_limiter' => true,
             ]);
diff --git a/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php b/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php
index fef16d0ce00e9..6cbaf48883553 100644
--- a/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php
+++ b/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php
@@ -18,6 +18,7 @@
 use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter;
 use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter;
 use Symfony\Component\RateLimiter\Storage\StorageInterface;
+use Symfony\Component\RateLimiter\Util\TimeUtil;
 
 /**
  * @author Kevin Bond <kevinbond@gmail.com>
@@ -39,7 +40,7 @@ public function __construct(
      */
     public function slidingWindow(string $id, int $limit, \DateInterval|string $interval): LimiterInterface
     {
-        return new SlidingWindowLimiter($id, $limit, self::intervalNormalizer($interval), $this->storage, $this->lock);
+        return new SlidingWindowLimiter($id, $limit, TimeUtil::intervalNormalizer($interval), $this->storage, $this->lock);
     }
 
     /**
@@ -51,7 +52,7 @@ public function slidingWindow(string $id, int $limit, \DateInterval|string $inte
      */
     public function fixedWindow(string $id, int $limit, \DateInterval|string $interval): LimiterInterface
     {
-        return new FixedWindowLimiter($id, $limit, self::intervalNormalizer($interval), $this->storage, $this->lock);
+        return new FixedWindowLimiter($id, $limit, TimeUtil::intervalNormalizer($interval), $this->storage, $this->lock);
     }
 
     /**
@@ -73,30 +74,4 @@ public function noop(): LimiterInterface
     {
         return new NoLimiter();
     }
-
-    /**
-     * @internal
-     */
-    public static function intervalNormalizer(\DateInterval|string $interval): \DateInterval
-    {
-        if ($interval instanceof \DateInterval) {
-            return $interval;
-        }
-
-        // Create DateTimeImmutable from unix timestamp, so the default timezone is ignored and we don't need to
-        // deal with quirks happening when modifying dates using a timezone with DST.
-        $now = \DateTimeImmutable::createFromFormat('U', time());
-
-        try {
-            $nowPlusInterval = @$now->modify('+'.$interval);
-        } catch (\DateMalformedStringException $e) {
-            throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval), 0, $e);
-        }
-
-        if (!$nowPlusInterval) {
-            throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval));
-        }
-
-        return $now->diff($nowPlusInterval);
-    }
 }
diff --git a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php
index 89026f01cca06..76edae2356fda 100644
--- a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php
+++ b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php
@@ -20,6 +20,7 @@
 use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter;
 use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter;
 use Symfony\Component\RateLimiter\Storage\StorageInterface;
+use Symfony\Component\RateLimiter\Util\TimeUtil;
 
 /**
  * @author Wouter de Jong <wouter@wouterj.nl>
@@ -56,7 +57,7 @@ public function create(?string $key = null): LimiterInterface
     private static function configureOptions(OptionsResolver $options): void
     {
         $intervalNormalizer = static function (Options $options, string $interval): \DateInterval {
-            return RateLimiterBuilder::intervalNormalizer($interval);
+            return TimeUtil::intervalNormalizer($interval);
         };
 
         $options
diff --git a/src/Symfony/Component/RateLimiter/Util/TimeUtil.php b/src/Symfony/Component/RateLimiter/Util/TimeUtil.php
index 30351d72c4c22..6099efce1e3a5 100644
--- a/src/Symfony/Component/RateLimiter/Util/TimeUtil.php
+++ b/src/Symfony/Component/RateLimiter/Util/TimeUtil.php
@@ -24,4 +24,27 @@ public static function dateIntervalToSeconds(\DateInterval $interval): int
 
         return $now->add($interval)->getTimestamp() - $now->getTimestamp();
     }
+
+    public static function intervalNormalizer(\DateInterval|string $interval): \DateInterval
+    {
+        if ($interval instanceof \DateInterval) {
+            return $interval;
+        }
+
+        // Create DateTimeImmutable from unix timestamp, so the default timezone is ignored and we don't need to
+        // deal with quirks happening when modifying dates using a timezone with DST.
+        $now = \DateTimeImmutable::createFromFormat('U', time());
+
+        try {
+            $nowPlusInterval = @$now->modify('+'.$interval);
+        } catch (\DateMalformedStringException $e) {
+            throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval), 0, $e);
+        }
+
+        if (!$nowPlusInterval) {
+            throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval));
+        }
+
+        return $now->diff($nowPlusInterval);
+    }
 }