• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 18419023611

10 Oct 2025 09:36PM UTC coverage: 84.433% (-0.006%) from 84.439%
18419023611

Pull #9749

github

web-flow
Merge 40e930908 into 01a45becc
Pull Request #9749: feat(app): Allow turning off defined routes and/or auto routing

65 of 69 new or added lines in 7 files covered. (94.2%)

15 existing lines in 3 files now uncovered.

21359 of 25297 relevant lines covered (84.43%)

195.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.5
/system/Router/RouteCollection.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Router;
15

16
use Closure;
17
use CodeIgniter\Autoloader\FileLocatorInterface;
18
use CodeIgniter\Exceptions\InvalidArgumentException;
19
use CodeIgniter\HTTP\Method;
20
use CodeIgniter\HTTP\ResponseInterface;
21
use CodeIgniter\Router\Exceptions\RouterException;
22
use Config\App;
23
use Config\Modules;
24
use Config\Routing;
25

26
/**
27
 * @todo Implement nested resource routing (See CakePHP)
28
 * @see \CodeIgniter\Router\RouteCollectionTest
29
 */
30
class RouteCollection implements RouteCollectionInterface
31
{
32
    /**
33
     * The namespace to be added to any Controllers.
34
     * Defaults to the global namespaces (\).
35
     *
36
     * This must have a trailing backslash (\).
37
     *
38
     * @var string
39
     */
40
    protected $defaultNamespace = '\\';
41

42
    /**
43
     * The name of the default controller to use
44
     * when no other controller is specified.
45
     *
46
     * Not used here. Pass-thru value for Router class.
47
     *
48
     * @var string
49
     */
50
    protected $defaultController = 'Home';
51

52
    /**
53
     * The name of the default method to use
54
     * when no other method has been specified.
55
     *
56
     * Not used here. Pass-thru value for Router class.
57
     *
58
     * @var string
59
     */
60
    protected $defaultMethod = 'index';
61

62
    /**
63
     * The placeholder used when routing 'resources'
64
     * when no other placeholder has been specified.
65
     *
66
     * @var string
67
     */
68
    protected $defaultPlaceholder = 'any';
69

70
    /**
71
     * Whether to convert dashes to underscores in URI.
72
     *
73
     * Not used here. Pass-thru value for Router class.
74
     *
75
     * @var bool
76
     */
77
    protected $translateURIDashes = false;
78

79
    /**
80
     * Whether to match URI against Controllers
81
     * when it doesn't match defined routes.
82
     *
83
     * Not used here. Pass-thru value for Router class.
84
     *
85
     * @var bool
86
     */
87
    protected $autoRoute = false;
88

89
    /**
90
     * Whether to use route definition files to define routes.
91
     *
92
     * Not used here. Pass-thru value for Router class.
93
     *
94
     * @var bool
95
     */
96
    protected $definedRoutes = true;
97

98
    /**
99
     * A callable that will be shown
100
     * when the route cannot be matched.
101
     *
102
     * @var (Closure(string): (ResponseInterface|string|void))|string
103
     */
104
    protected $override404;
105

106
    /**
107
     * An array of files that would contain route definitions.
108
     */
109
    protected array $routeFiles = [];
110

111
    /**
112
     * Defined placeholders that can be used
113
     * within the
114
     *
115
     * @var array<string, string>
116
     */
117
    protected $placeholders = [
118
        'any'      => '.*',
119
        'segment'  => '[^/]+',
120
        'alphanum' => '[a-zA-Z0-9]+',
121
        'num'      => '[0-9]+',
122
        'alpha'    => '[a-zA-Z]+',
123
        'hash'     => '[^/]+',
124
    ];
125

126
    /**
127
     * An array of all routes and their mappings.
128
     *
129
     * @var array
130
     *
131
     * [
132
     *     verb => [
133
     *         routeKey(regex) => [
134
     *             'name'    => routeName
135
     *             'handler' => handler,
136
     *             'from'    => from,
137
     *         ],
138
     *     ],
139
     *     // redirect route
140
     *     '*' => [
141
     *          routeKey(regex)(from) => [
142
     *             'name'     => routeName
143
     *             'handler'  => [routeKey(regex)(to) => handler],
144
     *             'from'     => from,
145
     *             'redirect' => statusCode,
146
     *         ],
147
     *     ],
148
     * ]
149
     */
150
    protected $routes = [
151
        '*'             => [],
152
        Method::OPTIONS => [],
153
        Method::GET     => [],
154
        Method::HEAD    => [],
155
        Method::POST    => [],
156
        Method::PATCH   => [],
157
        Method::PUT     => [],
158
        Method::DELETE  => [],
159
        Method::TRACE   => [],
160
        Method::CONNECT => [],
161
        'CLI'           => [],
162
    ];
163

164
    /**
165
     * Array of routes names
166
     *
167
     * @var array
168
     *
169
     * [
170
     *     verb => [
171
     *         routeName => routeKey(regex)
172
     *     ],
173
     * ]
174
     */
175
    protected $routesNames = [
176
        '*'             => [],
177
        Method::OPTIONS => [],
178
        Method::GET     => [],
179
        Method::HEAD    => [],
180
        Method::POST    => [],
181
        Method::PATCH   => [],
182
        Method::PUT     => [],
183
        Method::DELETE  => [],
184
        Method::TRACE   => [],
185
        Method::CONNECT => [],
186
        'CLI'           => [],
187
    ];
188

189
    /**
190
     * Array of routes options
191
     *
192
     * @var array
193
     *
194
     * [
195
     *     verb => [
196
     *         routeKey(regex) => [
197
     *             key => value,
198
     *         ]
199
     *     ],
200
     * ]
201
     */
202
    protected $routesOptions = [];
203

204
    /**
205
     * The current method that the script is being called by.
206
     *
207
     * @var string HTTP verb like `GET`,`POST` or `*` or `CLI`
208
     */
209
    protected $HTTPVerb = '*';
210

211
    /**
212
     * The default list of HTTP methods (and CLI for command line usage)
213
     * that is allowed if no other method is provided.
214
     *
215
     * @var list<string>
216
     */
217
    public $defaultHTTPMethods = Router::HTTP_METHODS;
218

219
    /**
220
     * The name of the current group, if any.
221
     *
222
     * @var string|null
223
     */
224
    protected $group;
225

226
    /**
227
     * The current subdomain.
228
     *
229
     * @var string|null
230
     */
231
    protected $currentSubdomain;
232

233
    /**
234
     * Stores copy of current options being
235
     * applied during creation.
236
     *
237
     * @var array|null
238
     */
239
    protected $currentOptions;
240

241
    /**
242
     * A little performance booster.
243
     *
244
     * @var bool
245
     */
246
    protected $didDiscover = false;
247

248
    /**
249
     * Handle to the file locator to use.
250
     *
251
     * @var FileLocatorInterface
252
     */
253
    protected $fileLocator;
254

255
    /**
256
     * Handle to the modules config.
257
     *
258
     * @var Modules
259
     */
260
    protected $moduleConfig;
261

262
    /**
263
     * Flag for sorting routes by priority.
264
     *
265
     * @var bool
266
     */
267
    protected $prioritize = false;
268

269
    /**
270
     * Route priority detection flag.
271
     *
272
     * @var bool
273
     */
274
    protected $prioritizeDetected = false;
275

276
    /**
277
     * The current hostname from $_SERVER['HTTP_HOST']
278
     */
279
    private ?string $httpHost = null;
280

281
    /**
282
     * Flag to limit or not the routes with {locale} placeholder to App::$supportedLocales
283
     */
284
    protected bool $useSupportedLocalesOnly = false;
285

286
    /**
287
     * Constructor
288
     */
289
    public function __construct(FileLocatorInterface $locator, Modules $moduleConfig, Routing $routing)
290
    {
291
        $this->fileLocator  = $locator;
507✔
292
        $this->moduleConfig = $moduleConfig;
507✔
293

294
        $this->httpHost = service('request')->getServer('HTTP_HOST');
507✔
295

296
        // Setup based on config file. Let routes file override.
297
        $this->defaultNamespace   = rtrim($routing->defaultNamespace, '\\') . '\\';
507✔
298
        $this->defaultController  = $routing->defaultController;
507✔
299
        $this->defaultMethod      = $routing->defaultMethod;
507✔
300
        $this->translateURIDashes = $routing->translateURIDashes;
507✔
301
        $this->override404        = $routing->override404;
507✔
302
        $this->autoRoute          = $routing->autoRoute;
507✔
303
        $this->definedRoutes      = $routing->definedRoutes;
507✔
304
        $this->routeFiles         = $routing->routeFiles;
507✔
305
        $this->prioritize         = $routing->prioritize;
507✔
306

307
        // Only normalize route files if we're actually going to use them
308
        if ($this->definedRoutes) {
507✔
309
            // Normalize the path string in routeFiles array.
310
            foreach ($this->routeFiles as $routeKey => $routesFile) {
501✔
311
                $realpath                    = realpath($routesFile);
501✔
312
                $this->routeFiles[$routeKey] = ($realpath === false) ? $routesFile : $realpath;
501✔
313
            }
314
        }
315
    }
316

317
    /**
318
     * Loads main routes file and discover routes.
319
     *
320
     * Loads only once unless reset.
321
     *
322
     * @return $this
323
     */
324
    public function loadRoutes(string $routesFile = APPPATH . 'Config/Routes.php')
325
    {
326
        // Skip loading if defined routes are disabled
327
        if (! $this->definedRoutes) {
215✔
328
            $this->didDiscover = true;
2✔
329

330
            return $this;
2✔
331
        }
332

333
        if ($this->didDiscover) {
213✔
334
            return $this;
25✔
335
        }
336

337
        // Normalize the path string in routesFile
338
        $realpath   = realpath($routesFile);
192✔
339
        $routesFile = ($realpath === false) ? $routesFile : $realpath;
192✔
340

341
        // Include the passed in routesFile if it doesn't exist.
342
        // Only keeping that around for BC purposes for now.
343
        $routeFiles = $this->routeFiles;
192✔
344
        if (! in_array($routesFile, $routeFiles, true)) {
192✔
345
            $routeFiles[] = $routesFile;
×
346
        }
347

348
        // We need this var in local scope
349
        // so route files can access it.
350
        $routes = $this;
192✔
351

352
        foreach ($routeFiles as $routesFile) {
192✔
353
            if (! is_file($routesFile)) {
192✔
354
                log_message('warning', sprintf('Routes file not found: "%s"', $routesFile));
×
355

356
                continue;
×
357
            }
358

359
            require $routesFile;
192✔
360
        }
361

362
        $this->discoverRoutes();
192✔
363

364
        return $this;
192✔
365
    }
366

367
    /**
368
     * Will attempt to discover any additional routes, either through
369
     * the local PSR4 namespaces, or through selected Composer packages.
370
     *
371
     * @return void
372
     */
373
    protected function discoverRoutes()
374
    {
375
        if ($this->didDiscover) {
374✔
376
            return;
98✔
377
        }
378

379
        // Skip discovery if defined routes are disabled
380
        if (! $this->definedRoutes) {
371✔
NEW
381
            $this->didDiscover = true;
×
382

NEW
383
            return;
×
384
        }
385

386
        // We need this var in local scope
387
        // so route files can access it.
388
        $routes = $this;
371✔
389

390
        if ($this->moduleConfig->shouldDiscover('routes')) {
371✔
391
            $files = $this->fileLocator->search('Config/Routes.php');
219✔
392

393
            foreach ($files as $file) {
219✔
394
                // Don't include our main file again...
395
                if (in_array($file, $this->routeFiles, true)) {
219✔
396
                    continue;
219✔
397
                }
398

399
                include $file;
219✔
400
            }
401
        }
402

403
        $this->didDiscover = true;
371✔
404
    }
405

406
    /**
407
     * Registers a new constraint with the system. Constraints are used
408
     * by the routes as placeholders for regular expressions to make defining
409
     * the routes more human-friendly.
410
     *
411
     * You can pass an associative array as $placeholder, and have
412
     * multiple placeholders added at once.
413
     *
414
     * @param array|string $placeholder
415
     */
416
    public function addPlaceholder($placeholder, ?string $pattern = null): RouteCollectionInterface
417
    {
418
        if (! is_array($placeholder)) {
5✔
419
            $placeholder = [$placeholder => $pattern];
4✔
420
        }
421

422
        $this->placeholders = array_merge($this->placeholders, $placeholder);
5✔
423

424
        return $this;
5✔
425
    }
426

427
    /**
428
     * For `spark routes`
429
     *
430
     * @return array<string, string>
431
     *
432
     * @internal
433
     */
434
    public function getPlaceholders(): array
435
    {
436
        return $this->placeholders;
15✔
437
    }
438

439
    /**
440
     * Sets the default namespace to use for Controllers when no other
441
     * namespace has been specified.
442
     */
443
    public function setDefaultNamespace(string $value): RouteCollectionInterface
444
    {
445
        $this->defaultNamespace = esc(strip_tags($value));
29✔
446
        $this->defaultNamespace = rtrim($this->defaultNamespace, '\\') . '\\';
29✔
447

448
        return $this;
29✔
449
    }
450

451
    /**
452
     * Sets the default controller to use when no other controller has been
453
     * specified.
454
     */
455
    public function setDefaultController(string $value): RouteCollectionInterface
456
    {
457
        $this->defaultController = esc(strip_tags($value));
12✔
458

459
        return $this;
12✔
460
    }
461

462
    /**
463
     * Sets the default method to call on the controller when no other
464
     * method has been set in the route.
465
     */
466
    public function setDefaultMethod(string $value): RouteCollectionInterface
467
    {
468
        $this->defaultMethod = esc(strip_tags($value));
9✔
469

470
        return $this;
9✔
471
    }
472

473
    /**
474
     * Tells the system whether to convert dashes in URI strings into
475
     * underscores. In some search engines, including Google, dashes
476
     * create more meaning and make it easier for the search engine to
477
     * find words and meaning in the URI for better SEO. But it
478
     * doesn't work well with PHP method names....
479
     */
480
    public function setTranslateURIDashes(bool $value): RouteCollectionInterface
481
    {
482
        $this->translateURIDashes = $value;
1✔
483

484
        return $this;
1✔
485
    }
486

487
    /**
488
     * If TRUE, the system will attempt to match the URI against
489
     * Controllers by matching each segment against folders/files
490
     * in APPPATH/Controllers, when a match wasn't found against
491
     * defined routes.
492
     *
493
     * If FALSE, will stop searching and do NO automatic routing.
494
     */
495
    public function setAutoRoute(bool $value): RouteCollectionInterface
496
    {
497
        $this->autoRoute = $value;
40✔
498

499
        return $this;
40✔
500
    }
501

502
    /**
503
     * Sets the class/method that should be called if routing doesn't
504
     * find a match. It can be either a closure or the controller/method
505
     * name exactly like a route is defined: Users::index
506
     *
507
     * This setting is passed to the Router class and handled there.
508
     *
509
     * @param callable|string|null $callable
510
     */
511
    public function set404Override($callable = null): RouteCollectionInterface
512
    {
513
        $this->override404 = $callable;
6✔
514

515
        return $this;
6✔
516
    }
517

518
    /**
519
     * Returns the 404 Override setting, which can be null, a closure
520
     * or the controller/string.
521
     *
522
     * @return (Closure(string): (ResponseInterface|string|void))|string|null
523
     */
524
    public function get404Override()
525
    {
526
        return $this->override404;
15✔
527
    }
528

529
    /**
530
     * Sets the default constraint to be used in the system. Typically
531
     * for use with the 'resource' method.
532
     */
533
    public function setDefaultConstraint(string $placeholder): RouteCollectionInterface
534
    {
535
        if (array_key_exists($placeholder, $this->placeholders)) {
2✔
536
            $this->defaultPlaceholder = $placeholder;
1✔
537
        }
538

539
        return $this;
2✔
540
    }
541

542
    /**
543
     * Returns the name of the default controller. With Namespace.
544
     */
545
    public function getDefaultController(): string
546
    {
547
        return $this->defaultController;
243✔
548
    }
549

550
    /**
551
     * Returns the name of the default method to use within the controller.
552
     */
553
    public function getDefaultMethod(): string
554
    {
555
        return $this->defaultMethod;
243✔
556
    }
557

558
    /**
559
     * Returns the default namespace as set in the Routes config file.
560
     */
561
    public function getDefaultNamespace(): string
562
    {
563
        return $this->defaultNamespace;
38✔
564
    }
565

566
    /**
567
     * Returns the current value of the translateURIDashes setting.
568
     */
569
    public function shouldTranslateURIDashes(): bool
570
    {
571
        return $this->translateURIDashes;
202✔
572
    }
573

574
    /**
575
     * Returns the flag that tells whether to autoRoute URI against Controllers.
576
     */
577
    public function shouldAutoRoute(): bool
578
    {
579
        return $this->autoRoute;
196✔
580
    }
581

582
    /**
583
     * Returns the flag that tells whether to use defined routes.
584
     */
585
    public function shouldUseDefinedRoutes(): bool
586
    {
587
        return $this->definedRoutes;
202✔
588
    }
589

590
    /**
591
     * Returns the raw array of available routes.
592
     *
593
     * @param non-empty-string|null $verb            HTTP verb like `GET`,`POST` or `*` or `CLI`.
594
     * @param bool                  $includeWildcard Whether to include '*' routes.
595
     */
596
    public function getRoutes(?string $verb = null, bool $includeWildcard = true): array
597
    {
598
        // Early exit if defined routes are disabled
599
        if (! $this->definedRoutes) {
267✔
600
            return [];
5✔
601
        }
602

603
        if ((string) $verb === '') {
262✔
604
            $verb = $this->getHTTPVerb();
58✔
605
        }
606

607
        // Since this is the entry point for the Router,
608
        // take a moment to do any route discovery
609
        // we might need to do.
610
        $this->discoverRoutes();
262✔
611

612
        $routes = [];
262✔
613

614
        if (isset($this->routes[$verb])) {
262✔
615
            // Keep current verb's routes at the beginning, so they're matched
616
            // before any of the generic, "add" routes.
617
            $collection = $includeWildcard ? $this->routes[$verb] + ($this->routes['*'] ?? []) : $this->routes[$verb];
262✔
618

619
            foreach ($collection as $routeKey => $r) {
262✔
620
                $routes[$routeKey] = $r['handler'];
239✔
621
            }
622
        }
623

624
        // sorting routes by priority
625
        if ($this->prioritizeDetected && $this->prioritize && $routes !== []) {
262✔
626
            $order = [];
1✔
627

628
            foreach ($routes as $key => $value) {
1✔
629
                $key                    = $key === '/' ? $key : ltrim($key, '/ ');
1✔
630
                $priority               = $this->getRoutesOptions($key, $verb)['priority'] ?? 0;
1✔
631
                $order[$priority][$key] = $value;
1✔
632
            }
633

634
            ksort($order);
1✔
635
            $routes = array_merge(...$order);
1✔
636
        }
637

638
        return $routes;
262✔
639
    }
640

641
    /**
642
     * Returns one or all routes options
643
     *
644
     * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`.
645
     *
646
     * @return array<string, int|string> [key => value]
647
     */
648
    public function getRoutesOptions(?string $from = null, ?string $verb = null): array
649
    {
650
        $options = $this->loadRoutesOptions($verb);
149✔
651

652
        return ((string) $from !== '') ? $options[$from] ?? [] : $options;
149✔
653
    }
654

655
    /**
656
     * Returns the current HTTP Verb being used.
657
     */
658
    public function getHTTPVerb(): string
659
    {
660
        return $this->HTTPVerb;
272✔
661
    }
662

663
    /**
664
     * Sets the current HTTP verb.
665
     * Used primarily for testing.
666
     *
667
     * @param string $verb HTTP verb
668
     *
669
     * @return $this
670
     */
671
    public function setHTTPVerb(string $verb)
672
    {
673
        if ($verb !== '*' && $verb === strtolower($verb)) {
326✔
674
            @trigger_error(
×
675
                'Passing lowercase HTTP method "' . $verb . '" is deprecated.'
×
676
                . ' Use uppercase HTTP method like "' . strtoupper($verb) . '".',
×
677
                E_USER_DEPRECATED,
×
678
            );
×
679
        }
680

681
        /**
682
         * @deprecated 4.5.0
683
         * @TODO Remove strtoupper() in the future.
684
         */
685
        $this->HTTPVerb = strtoupper($verb);
326✔
686

687
        return $this;
326✔
688
    }
689

690
    /**
691
     * A shortcut method to add a number of routes at a single time.
692
     * It does not allow any options to be set on the route, or to
693
     * define the method used.
694
     */
695
    public function map(array $routes = [], ?array $options = null): RouteCollectionInterface
696
    {
697
        foreach ($routes as $from => $to) {
74✔
698
            $this->add($from, $to, $options);
74✔
699
        }
700

701
        return $this;
74✔
702
    }
703

704
    /**
705
     * Adds a single route to the collection.
706
     *
707
     * Example:
708
     *      $routes->add('news', 'Posts::index');
709
     *
710
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
711
     */
712
    public function add(string $from, $to, ?array $options = null): RouteCollectionInterface
713
    {
714
        $this->create('*', $from, $to, $options);
357✔
715

716
        return $this;
357✔
717
    }
718

719
    /**
720
     * Adds a temporary redirect from one route to another. Used for
721
     * redirecting traffic from old, non-existing routes to the new
722
     * moved routes.
723
     *
724
     * @param string $from   The pattern to match against
725
     * @param string $to     Either a route name or a URI to redirect to
726
     * @param int    $status The HTTP status code that should be returned with this redirect
727
     *
728
     * @return RouteCollection
729
     */
730
    public function addRedirect(string $from, string $to, int $status = 302)
731
    {
732
        // Use the named route's pattern if this is a named route.
733
        if (array_key_exists($to, $this->routesNames['*'])) {
16✔
734
            $routeName  = $to;
3✔
735
            $routeKey   = $this->routesNames['*'][$routeName];
3✔
736
            $redirectTo = [$routeKey => $this->routes['*'][$routeKey]['handler']];
3✔
737
        } elseif (array_key_exists($to, $this->routesNames[Method::GET])) {
13✔
738
            $routeName  = $to;
4✔
739
            $routeKey   = $this->routesNames[Method::GET][$routeName];
4✔
740
            $redirectTo = [$routeKey => $this->routes[Method::GET][$routeKey]['handler']];
4✔
741
        } else {
742
            // The named route is not found.
743
            $redirectTo = $to;
9✔
744
        }
745

746
        $this->create('*', $from, $redirectTo, ['redirect' => $status]);
16✔
747

748
        return $this;
16✔
749
    }
750

751
    /**
752
     * Determines if the route is a redirecting route.
753
     *
754
     * @param string $routeKey routeKey or route name
755
     */
756
    public function isRedirect(string $routeKey): bool
757
    {
758
        if (isset($this->routes['*'][$routeKey]['redirect'])) {
155✔
759
            return true;
16✔
760
        }
761

762
        // This logic is not used. Should be deprecated?
763
        $routeName = $this->routes['*'][$routeKey]['name'] ?? null;
139✔
764
        if ($routeName === $routeKey) {
139✔
765
            $routeKey = $this->routesNames['*'][$routeName];
38✔
766

767
            return isset($this->routes['*'][$routeKey]['redirect']);
38✔
768
        }
769

770
        return false;
102✔
771
    }
772

773
    /**
774
     * Grabs the HTTP status code from a redirecting Route.
775
     *
776
     * @param string $routeKey routeKey or route name
777
     */
778
    public function getRedirectCode(string $routeKey): int
779
    {
780
        if (isset($this->routes['*'][$routeKey]['redirect'])) {
16✔
781
            return $this->routes['*'][$routeKey]['redirect'];
16✔
782
        }
783

784
        // This logic is not used. Should be deprecated?
785
        $routeName = $this->routes['*'][$routeKey]['name'] ?? null;
1✔
786
        if ($routeName === $routeKey) {
1✔
787
            $routeKey = $this->routesNames['*'][$routeName];
×
788

789
            return $this->routes['*'][$routeKey]['redirect'];
×
790
        }
791

792
        return 0;
1✔
793
    }
794

795
    /**
796
     * Group a series of routes under a single URL segment. This is handy
797
     * for grouping items into an admin area, like:
798
     *
799
     * Example:
800
     *     // Creates route: admin/users
801
     *     $route->group('admin', function() {
802
     *            $route->resource('users');
803
     *     });
804
     *
805
     * @param string         $name      The name to group/prefix the routes with.
806
     * @param array|callable ...$params
807
     *
808
     * @return void
809
     */
810
    public function group(string $name, ...$params)
811
    {
812
        $oldGroup   = $this->group;
18✔
813
        $oldOptions = $this->currentOptions;
18✔
814

815
        // To register a route, we'll set a flag so that our router
816
        // will see the group name.
817
        // If the group name is empty, we go on using the previously built group name.
818
        $this->group = $name !== '' ? trim($oldGroup . '/' . $name, '/') : $oldGroup;
18✔
819

820
        $callback = array_pop($params);
18✔
821

822
        if ($params !== [] && is_array($params[0])) {
18✔
823
            $options = array_shift($params);
9✔
824

825
            if (isset($options['filter'])) {
9✔
826
                // Merge filters.
827
                $currentFilter     = (array) ($this->currentOptions['filter'] ?? []);
8✔
828
                $options['filter'] = array_merge($currentFilter, (array) $options['filter']);
8✔
829
            }
830

831
            // Merge options other than filters.
832
            $this->currentOptions = array_merge(
9✔
833
                $this->currentOptions ?? [],
9✔
834
                $options,
9✔
835
            );
9✔
836
        }
837

838
        if (is_callable($callback)) {
18✔
839
            $callback($this);
18✔
840
        }
841

842
        $this->group          = $oldGroup;
18✔
843
        $this->currentOptions = $oldOptions;
18✔
844
    }
845

846
    /*
847
     * --------------------------------------------------------------------
848
     *  HTTP Verb-based routing
849
     * --------------------------------------------------------------------
850
     * Routing works here because, as the routes Config file is read in,
851
     * the various HTTP verb-based routes will only be added to the in-memory
852
     * routes if it is a call that should respond to that verb.
853
     *
854
     * The options array is typically used to pass in an 'as' or var, but may
855
     * be expanded in the future. See the docblock for 'add' method above for
856
     * current list of globally available options.
857
     */
858

859
    /**
860
     * Creates a collections of HTTP-verb based routes for a controller.
861
     *
862
     * Possible Options:
863
     *      'controller'    - Customize the name of the controller used in the 'to' route
864
     *      'placeholder'   - The regex used by the Router. Defaults to '(:any)'
865
     *      'websafe'   -        - '1' if only GET and POST HTTP verbs are supported
866
     *
867
     * Example:
868
     *
869
     *      $route->resource('photos');
870
     *
871
     *      // Generates the following routes:
872
     *      HTTP Verb | Path        | Action        | Used for...
873
     *      ----------+-------------+---------------+-----------------
874
     *      GET         /photos             index           an array of photo objects
875
     *      GET         /photos/new         new             an empty photo object, with default properties
876
     *      GET         /photos/{id}/edit   edit            a specific photo object, editable properties
877
     *      GET         /photos/{id}        show            a specific photo object, all properties
878
     *      POST        /photos             create          a new photo object, to add to the resource
879
     *      DELETE      /photos/{id}        delete          deletes the specified photo object
880
     *      PUT/PATCH   /photos/{id}        update          replacement properties for existing photo
881
     *
882
     *  If 'websafe' option is present, the following paths are also available:
883
     *
884
     *      POST                /photos/{id}/delete delete
885
     *      POST        /photos/{id}        update
886
     *
887
     * @param string     $name    The name of the resource/controller to route to.
888
     * @param array|null $options A list of possible ways to customize the routing.
889
     */
890
    public function resource(string $name, ?array $options = null): RouteCollectionInterface
891
    {
892
        // In order to allow customization of the route the
893
        // resources are sent to, we need to have a new name
894
        // to store the values in.
895
        $newName = implode('\\', array_map(ucfirst(...), explode('/', $name)));
18✔
896

897
        // If a new controller is specified, then we replace the
898
        // $name value with the name of the new controller.
899
        if (isset($options['controller'])) {
18✔
900
            $newName = ucfirst(esc(strip_tags($options['controller'])));
11✔
901
        }
902

903
        // In order to allow customization of allowed id values
904
        // we need someplace to store them.
905
        $id = $options['placeholder'] ?? $this->placeholders[$this->defaultPlaceholder] ?? '(:segment)';
18✔
906

907
        // Make sure we capture back-references
908
        $id = '(' . trim($id, '()') . ')';
18✔
909

910
        $methods = isset($options['only']) ? (is_string($options['only']) ? explode(',', $options['only']) : $options['only']) : ['index', 'show', 'create', 'update', 'delete', 'new', 'edit'];
18✔
911

912
        if (isset($options['except'])) {
18✔
913
            $options['except'] = is_array($options['except']) ? $options['except'] : explode(',', $options['except']);
1✔
914

915
            foreach ($methods as $i => $method) {
1✔
916
                if (in_array($method, $options['except'], true)) {
1✔
917
                    unset($methods[$i]);
1✔
918
                }
919
            }
920
        }
921

922
        if (in_array('index', $methods, true)) {
18✔
923
            $this->get($name, $newName . '::index', $options);
18✔
924
        }
925
        if (in_array('new', $methods, true)) {
18✔
926
            $this->get($name . '/new', $newName . '::new', $options);
16✔
927
        }
928
        if (in_array('edit', $methods, true)) {
18✔
929
            $this->get($name . '/' . $id . '/edit', $newName . '::edit/$1', $options);
16✔
930
        }
931
        if (in_array('show', $methods, true)) {
18✔
932
            $this->get($name . '/' . $id, $newName . '::show/$1', $options);
17✔
933
        }
934
        if (in_array('create', $methods, true)) {
18✔
935
            $this->post($name, $newName . '::create', $options);
17✔
936
        }
937
        if (in_array('update', $methods, true)) {
18✔
938
            $this->put($name . '/' . $id, $newName . '::update/$1', $options);
17✔
939
            $this->patch($name . '/' . $id, $newName . '::update/$1', $options);
17✔
940
        }
941
        if (in_array('delete', $methods, true)) {
18✔
942
            $this->delete($name . '/' . $id, $newName . '::delete/$1', $options);
17✔
943
        }
944

945
        // Web Safe? delete needs checking before update because of method name
946
        if (isset($options['websafe'])) {
18✔
947
            if (in_array('delete', $methods, true)) {
1✔
948
                $this->post($name . '/' . $id . '/delete', $newName . '::delete/$1', $options);
1✔
949
            }
950
            if (in_array('update', $methods, true)) {
1✔
951
                $this->post($name . '/' . $id, $newName . '::update/$1', $options);
1✔
952
            }
953
        }
954

955
        return $this;
18✔
956
    }
957

958
    /**
959
     * Creates a collections of HTTP-verb based routes for a presenter controller.
960
     *
961
     * Possible Options:
962
     *      'controller'    - Customize the name of the controller used in the 'to' route
963
     *      'placeholder'   - The regex used by the Router. Defaults to '(:any)'
964
     *
965
     * Example:
966
     *
967
     *      $route->presenter('photos');
968
     *
969
     *      // Generates the following routes:
970
     *      HTTP Verb | Path        | Action        | Used for...
971
     *      ----------+-------------+---------------+-----------------
972
     *      GET         /photos             index           showing all array of photo objects
973
     *      GET         /photos/show/{id}   show            showing a specific photo object, all properties
974
     *      GET         /photos/new         new             showing a form for an empty photo object, with default properties
975
     *      POST        /photos/create      create          processing the form for a new photo
976
     *      GET         /photos/edit/{id}   edit            show an editing form for a specific photo object, editable properties
977
     *      POST        /photos/update/{id} update          process the editing form data
978
     *      GET         /photos/remove/{id} remove          show a form to confirm deletion of a specific photo object
979
     *      POST        /photos/delete/{id} delete          deleting the specified photo object
980
     *
981
     * @param string     $name    The name of the controller to route to.
982
     * @param array|null $options A list of possible ways to customize the routing.
983
     */
984
    public function presenter(string $name, ?array $options = null): RouteCollectionInterface
985
    {
986
        // In order to allow customization of the route the
987
        // resources are sent to, we need to have a new name
988
        // to store the values in.
989
        $newName = implode('\\', array_map(ucfirst(...), explode('/', $name)));
9✔
990

991
        // If a new controller is specified, then we replace the
992
        // $name value with the name of the new controller.
993
        if (isset($options['controller'])) {
9✔
994
            $newName = ucfirst(esc(strip_tags($options['controller'])));
8✔
995
        }
996

997
        // In order to allow customization of allowed id values
998
        // we need someplace to store them.
999
        $id = $options['placeholder'] ?? $this->placeholders[$this->defaultPlaceholder] ?? '(:segment)';
9✔
1000

1001
        // Make sure we capture back-references
1002
        $id = '(' . trim($id, '()') . ')';
9✔
1003

1004
        $methods = isset($options['only']) ? (is_string($options['only']) ? explode(',', $options['only']) : $options['only']) : ['index', 'show', 'new', 'create', 'edit', 'update', 'remove', 'delete'];
9✔
1005

1006
        if (isset($options['except'])) {
9✔
1007
            $options['except'] = is_array($options['except']) ? $options['except'] : explode(',', $options['except']);
×
1008

1009
            foreach ($methods as $i => $method) {
×
1010
                if (in_array($method, $options['except'], true)) {
×
1011
                    unset($methods[$i]);
×
1012
                }
1013
            }
1014
        }
1015

1016
        if (in_array('index', $methods, true)) {
9✔
1017
            $this->get($name, $newName . '::index', $options);
9✔
1018
        }
1019
        if (in_array('show', $methods, true)) {
9✔
1020
            $this->get($name . '/show/' . $id, $newName . '::show/$1', $options);
9✔
1021
        }
1022
        if (in_array('new', $methods, true)) {
9✔
1023
            $this->get($name . '/new', $newName . '::new', $options);
9✔
1024
        }
1025
        if (in_array('create', $methods, true)) {
9✔
1026
            $this->post($name . '/create', $newName . '::create', $options);
9✔
1027
        }
1028
        if (in_array('edit', $methods, true)) {
9✔
1029
            $this->get($name . '/edit/' . $id, $newName . '::edit/$1', $options);
9✔
1030
        }
1031
        if (in_array('update', $methods, true)) {
9✔
1032
            $this->post($name . '/update/' . $id, $newName . '::update/$1', $options);
9✔
1033
        }
1034
        if (in_array('remove', $methods, true)) {
9✔
1035
            $this->get($name . '/remove/' . $id, $newName . '::remove/$1', $options);
9✔
1036
        }
1037
        if (in_array('delete', $methods, true)) {
9✔
1038
            $this->post($name . '/delete/' . $id, $newName . '::delete/$1', $options);
9✔
1039
        }
1040
        if (in_array('show', $methods, true)) {
9✔
1041
            $this->get($name . '/' . $id, $newName . '::show/$1', $options);
9✔
1042
        }
1043
        if (in_array('create', $methods, true)) {
9✔
1044
            $this->post($name, $newName . '::create', $options);
9✔
1045
        }
1046

1047
        return $this;
9✔
1048
    }
1049

1050
    /**
1051
     * Specifies a single route to match for multiple HTTP Verbs.
1052
     *
1053
     * Example:
1054
     *  $route->match( ['GET', 'POST'], 'users/(:num)', 'users/$1);
1055
     *
1056
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1057
     */
1058
    public function match(array $verbs = [], string $from = '', $to = '', ?array $options = null): RouteCollectionInterface
1059
    {
1060
        if ($from === '' || empty($to)) {
4✔
1061
            throw new InvalidArgumentException('You must supply the parameters: from, to.');
×
1062
        }
1063

1064
        foreach ($verbs as $verb) {
4✔
1065
            if ($verb === strtolower($verb)) {
4✔
1066
                @trigger_error(
×
1067
                    'Passing lowercase HTTP method "' . $verb . '" is deprecated.'
×
1068
                    . ' Use uppercase HTTP method like "' . strtoupper($verb) . '".',
×
1069
                    E_USER_DEPRECATED,
×
1070
                );
×
1071
            }
1072

1073
            /**
1074
             * @TODO We should use correct uppercase verb.
1075
             * @deprecated 4.5.0
1076
             */
1077
            $verb = strtolower($verb);
4✔
1078

1079
            $this->{$verb}($from, $to, $options);
4✔
1080
        }
1081

1082
        return $this;
4✔
1083
    }
1084

1085
    /**
1086
     * Specifies a route that is only available to GET requests.
1087
     *
1088
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1089
     */
1090
    public function get(string $from, $to, ?array $options = null): RouteCollectionInterface
1091
    {
1092
        $this->create(Method::GET, $from, $to, $options);
305✔
1093

1094
        return $this;
305✔
1095
    }
1096

1097
    /**
1098
     * Specifies a route that is only available to POST requests.
1099
     *
1100
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1101
     */
1102
    public function post(string $from, $to, ?array $options = null): RouteCollectionInterface
1103
    {
1104
        $this->create(Method::POST, $from, $to, $options);
47✔
1105

1106
        return $this;
47✔
1107
    }
1108

1109
    /**
1110
     * Specifies a route that is only available to PUT requests.
1111
     *
1112
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1113
     */
1114
    public function put(string $from, $to, ?array $options = null): RouteCollectionInterface
1115
    {
1116
        $this->create(Method::PUT, $from, $to, $options);
25✔
1117

1118
        return $this;
25✔
1119
    }
1120

1121
    /**
1122
     * Specifies a route that is only available to DELETE requests.
1123
     *
1124
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1125
     */
1126
    public function delete(string $from, $to, ?array $options = null): RouteCollectionInterface
1127
    {
1128
        $this->create(Method::DELETE, $from, $to, $options);
19✔
1129

1130
        return $this;
19✔
1131
    }
1132

1133
    /**
1134
     * Specifies a route that is only available to HEAD requests.
1135
     *
1136
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1137
     */
1138
    public function head(string $from, $to, ?array $options = null): RouteCollectionInterface
1139
    {
1140
        $this->create(Method::HEAD, $from, $to, $options);
1✔
1141

1142
        return $this;
1✔
1143
    }
1144

1145
    /**
1146
     * Specifies a route that is only available to PATCH requests.
1147
     *
1148
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1149
     */
1150
    public function patch(string $from, $to, ?array $options = null): RouteCollectionInterface
1151
    {
1152
        $this->create(Method::PATCH, $from, $to, $options);
19✔
1153

1154
        return $this;
19✔
1155
    }
1156

1157
    /**
1158
     * Specifies a route that is only available to OPTIONS requests.
1159
     *
1160
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1161
     */
1162
    public function options(string $from, $to, ?array $options = null): RouteCollectionInterface
1163
    {
1164
        $this->create(Method::OPTIONS, $from, $to, $options);
3✔
1165

1166
        return $this;
3✔
1167
    }
1168

1169
    /**
1170
     * Specifies a route that is only available to command-line requests.
1171
     *
1172
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1173
     */
1174
    public function cli(string $from, $to, ?array $options = null): RouteCollectionInterface
1175
    {
1176
        $this->create('CLI', $from, $to, $options);
7✔
1177

1178
        return $this;
7✔
1179
    }
1180

1181
    /**
1182
     * Specifies a route that will only display a view.
1183
     * Only works for GET requests.
1184
     */
1185
    public function view(string $from, string $view, ?array $options = null): RouteCollectionInterface
1186
    {
1187
        $to = static fn (...$data) => service('renderer')
2✔
1188
            ->setData(['segments' => $data], 'raw')
2✔
1189
            ->render($view, $options);
2✔
1190

1191
        $routeOptions = $options ?? [];
2✔
1192
        $routeOptions = array_merge($routeOptions, ['view' => $view]);
2✔
1193

1194
        $this->create(Method::GET, $from, $to, $routeOptions);
2✔
1195

1196
        return $this;
2✔
1197
    }
1198

1199
    /**
1200
     * Limits the routes to a specified ENVIRONMENT or they won't run.
1201
     *
1202
     * @param Closure(RouteCollection): void $callback
1203
     */
1204
    public function environment(string $env, Closure $callback): RouteCollectionInterface
1205
    {
1206
        if ($env === ENVIRONMENT) {
1✔
1207
            $callback($this);
1✔
1208
        }
1209

1210
        return $this;
1✔
1211
    }
1212

1213
    /**
1214
     * Attempts to look up a route based on its destination.
1215
     *
1216
     * If a route exists:
1217
     *
1218
     *      'path/(:any)/(:any)' => 'Controller::method/$1/$2'
1219
     *
1220
     * This method allows you to know the Controller and method
1221
     * and get the route that leads to it.
1222
     *
1223
     *      // Equals 'path/$param1/$param2'
1224
     *      reverseRoute('Controller::method', $param1, $param2);
1225
     *
1226
     * @param string     $search    Route name or Controller::method
1227
     * @param int|string ...$params One or more parameters to be passed to the route.
1228
     *                              The last parameter allows you to set the locale.
1229
     *
1230
     * @return false|string The route (URI path relative to baseURL) or false if not found.
1231
     */
1232
    public function reverseRoute(string $search, ...$params)
1233
    {
1234
        if ($search === '') {
52✔
1235
            return false;
2✔
1236
        }
1237

1238
        // Named routes get higher priority.
1239
        foreach ($this->routesNames as $verb => $collection) {
50✔
1240
            if (array_key_exists($search, $collection)) {
50✔
1241
                $routeKey = $collection[$search];
26✔
1242

1243
                $from = $this->routes[$verb][$routeKey]['from'];
26✔
1244

1245
                return $this->buildReverseRoute($from, $params);
26✔
1246
            }
1247
        }
1248

1249
        // Add the default namespace if needed.
1250
        $namespace = trim($this->defaultNamespace, '\\') . '\\';
24✔
1251
        if (
1252
            ! str_starts_with($search, '\\')
24✔
1253
            && ! str_starts_with($search, $namespace)
24✔
1254
        ) {
1255
            $search = $namespace . $search;
22✔
1256
        }
1257

1258
        // If it's not a named route, then loop over
1259
        // all routes to find a match.
1260
        foreach ($this->routes as $collection) {
24✔
1261
            foreach ($collection as $route) {
24✔
1262
                $to   = $route['handler'];
19✔
1263
                $from = $route['from'];
19✔
1264

1265
                // ignore closures
1266
                if (! is_string($to)) {
19✔
1267
                    continue;
3✔
1268
                }
1269

1270
                // Lose any namespace slash at beginning of strings
1271
                // to ensure more consistent match.
1272
                $to     = ltrim($to, '\\');
18✔
1273
                $search = ltrim($search, '\\');
18✔
1274

1275
                // If there's any chance of a match, then it will
1276
                // be with $search at the beginning of the $to string.
1277
                if (! str_starts_with($to, $search)) {
18✔
1278
                    continue;
6✔
1279
                }
1280

1281
                // Ensure that the number of $params given here
1282
                // matches the number of back-references in the route
1283
                if (substr_count($to, '$') !== count($params)) {
14✔
1284
                    continue;
1✔
1285
                }
1286

1287
                return $this->buildReverseRoute($from, $params);
13✔
1288
            }
1289
        }
1290

1291
        // If we're still here, then we did not find a match.
1292
        return false;
11✔
1293
    }
1294

1295
    /**
1296
     * Replaces the {locale} tag with the current application locale
1297
     *
1298
     * @deprecated Unused.
1299
     */
1300
    protected function localizeRoute(string $route): string
1301
    {
1302
        return strtr($route, ['{locale}' => service('request')->getLocale()]);
×
1303
    }
1304

1305
    /**
1306
     * Checks a route (using the "from") to see if it's filtered or not.
1307
     *
1308
     * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`.
1309
     */
1310
    public function isFiltered(string $search, ?string $verb = null): bool
1311
    {
1312
        $options = $this->loadRoutesOptions($verb);
138✔
1313

1314
        return isset($options[$search]['filter']);
138✔
1315
    }
1316

1317
    /**
1318
     * Returns the filters that should be applied for a single route, along
1319
     * with any parameters it might have. Parameters are found by splitting
1320
     * the parameter name on a colon to separate the filter name from the parameter list,
1321
     * and the splitting the result on commas. So:
1322
     *
1323
     *    'role:admin,manager'
1324
     *
1325
     * has a filter of "role", with parameters of ['admin', 'manager'].
1326
     *
1327
     * @param string      $search routeKey
1328
     * @param string|null $verb   HTTP verb like `GET`,`POST` or `*` or `CLI`.
1329
     *
1330
     * @return list<string> filter_name or filter_name:arguments like 'role:admin,manager'
1331
     */
1332
    public function getFiltersForRoute(string $search, ?string $verb = null): array
1333
    {
1334
        $options = $this->loadRoutesOptions($verb);
22✔
1335

1336
        if (! array_key_exists($search, $options) || ! array_key_exists('filter', $options[$search])) {
22✔
1337
            return [];
6✔
1338
        }
1339

1340
        if (is_string($options[$search]['filter'])) {
17✔
1341
            return [$options[$search]['filter']];
×
1342
        }
1343

1344
        return $options[$search]['filter'];
17✔
1345
    }
1346

1347
    /**
1348
     * Given a
1349
     *
1350
     * @throws RouterException
1351
     *
1352
     * @deprecated Unused. Now uses buildReverseRoute().
1353
     */
1354
    protected function fillRouteParams(string $from, ?array $params = null): string
1355
    {
1356
        // Find all of our back-references in the original route
1357
        preg_match_all('/\(([^)]+)\)/', $from, $matches);
×
1358

1359
        if (empty($matches[0])) {
×
1360
            return '/' . ltrim($from, '/');
×
1361
        }
1362

1363
        /**
1364
         * Build our resulting string, inserting the $params in
1365
         * the appropriate places.
1366
         *
1367
         * @var list<string> $patterns
1368
         */
1369
        $patterns = $matches[0];
×
1370

1371
        foreach ($patterns as $index => $pattern) {
×
1372
            if (preg_match('#^' . $pattern . '$#u', $params[$index]) !== 1) {
×
1373
                throw RouterException::forInvalidParameterType();
×
1374
            }
1375

1376
            // Ensure that the param we're inserting matches
1377
            // the expected param type.
1378
            $pos  = strpos($from, $pattern);
×
1379
            $from = substr_replace($from, $params[$index], $pos, strlen($pattern));
×
1380
        }
1381

1382
        return '/' . ltrim($from, '/');
×
1383
    }
1384

1385
    /**
1386
     * Builds reverse route
1387
     *
1388
     * @param array $params One or more parameters to be passed to the route.
1389
     *                      The last parameter allows you to set the locale.
1390
     */
1391
    protected function buildReverseRoute(string $from, array $params): string
1392
    {
1393
        $locale = null;
39✔
1394

1395
        // Find all of our back-references in the original route
1396
        preg_match_all('/\(([^)]+)\)/', $from, $matches);
39✔
1397

1398
        if (empty($matches[0])) {
39✔
1399
            if (str_contains($from, '{locale}')) {
12✔
1400
                $locale = $params[0] ?? null;
3✔
1401
            }
1402

1403
            $from = $this->replaceLocale($from, $locale);
12✔
1404

1405
            return '/' . ltrim($from, '/');
12✔
1406
        }
1407

1408
        // Locale is passed?
1409
        $placeholderCount = count($matches[0]);
27✔
1410
        if (count($params) > $placeholderCount) {
27✔
1411
            $locale = $params[$placeholderCount];
3✔
1412
        }
1413

1414
        /**
1415
         * Build our resulting string, inserting the $params in
1416
         * the appropriate places.
1417
         *
1418
         * @var list<string> $placeholders
1419
         */
1420
        $placeholders = $matches[0];
27✔
1421

1422
        foreach ($placeholders as $index => $placeholder) {
27✔
1423
            if (! isset($params[$index])) {
27✔
1424
                throw new InvalidArgumentException(
1✔
1425
                    'Missing argument for "' . $placeholder . '" in route "' . $from . '".',
1✔
1426
                );
1✔
1427
            }
1428

1429
            // Remove `(:` and `)` when $placeholder is a placeholder.
1430
            $placeholderName = substr($placeholder, 2, -1);
26✔
1431
            // or maybe $placeholder is not a placeholder, but a regex.
1432
            $pattern = $this->placeholders[$placeholderName] ?? $placeholder;
26✔
1433

1434
            if (preg_match('#^' . $pattern . '$#u', (string) $params[$index]) !== 1) {
26✔
1435
                throw RouterException::forInvalidParameterType();
1✔
1436
            }
1437

1438
            // Ensure that the param we're inserting matches
1439
            // the expected param type.
1440
            $pos  = strpos($from, $placeholder);
26✔
1441
            $from = substr_replace($from, (string) $params[$index], $pos, strlen($placeholder));
26✔
1442
        }
1443

1444
        $from = $this->replaceLocale($from, $locale);
25✔
1445

1446
        return '/' . ltrim($from, '/');
25✔
1447
    }
1448

1449
    /**
1450
     * Replaces the {locale} tag with the locale
1451
     */
1452
    private function replaceLocale(string $route, ?string $locale = null): string
1453
    {
1454
        if (! str_contains($route, '{locale}')) {
37✔
1455
            return $route;
28✔
1456
        }
1457

1458
        // Check invalid locale
1459
        if ((string) $locale !== '') {
9✔
1460
            $config = config(App::class);
3✔
1461
            if (! in_array($locale, $config->supportedLocales, true)) {
3✔
1462
                $locale = null;
1✔
1463
            }
1464
        }
1465

1466
        if ((string) $locale === '') {
9✔
1467
            $locale = service('request')->getLocale();
7✔
1468
        }
1469

1470
        return strtr($route, ['{locale}' => $locale]);
9✔
1471
    }
1472

1473
    /**
1474
     * Does the heavy lifting of creating an actual route. You must specify
1475
     * the request method(s) that this route will work for. They can be separated
1476
     * by a pipe character "|" if there is more than one.
1477
     *
1478
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1479
     *
1480
     * @return void
1481
     */
1482
    protected function create(string $verb, string $from, $to, ?array $options = null)
1483
    {
1484
        $overwrite = false;
433✔
1485
        $prefix    = $this->group === null ? '' : $this->group . '/';
433✔
1486

1487
        $from = esc(strip_tags($prefix . $from));
433✔
1488

1489
        // While we want to add a route within a group of '/',
1490
        // it doesn't work with matching, so remove them...
1491
        if ($from !== '/') {
433✔
1492
            $from = trim($from, '/');
424✔
1493
        }
1494

1495
        // When redirecting to named route, $to is an array like `['zombies' => '\Zombies::index']`.
1496
        if (is_array($to) && isset($to[0])) {
433✔
1497
            $to = $this->processArrayCallableSyntax($from, $to);
3✔
1498
        }
1499

1500
        // Merge group filters.
1501
        if (isset($options['filter'])) {
433✔
1502
            $currentFilter     = (array) ($this->currentOptions['filter'] ?? []);
14✔
1503
            $options['filter'] = array_merge($currentFilter, (array) $options['filter']);
14✔
1504
        }
1505

1506
        $options = array_merge($this->currentOptions ?? [], $options ?? []);
433✔
1507

1508
        // Route priority detect
1509
        if (isset($options['priority'])) {
433✔
1510
            $options['priority'] = abs((int) $options['priority']);
3✔
1511

1512
            if ($options['priority'] > 0) {
3✔
1513
                $this->prioritizeDetected = true;
3✔
1514
            }
1515
        }
1516

1517
        // Hostname limiting?
1518
        if (! empty($options['hostname'])) {
433✔
1519
            // @todo determine if there's a way to whitelist hosts?
1520
            if (! $this->checkHostname($options['hostname'])) {
226✔
1521
                return;
222✔
1522
            }
1523

1524
            $overwrite = true;
5✔
1525
        }
1526
        // Limiting to subdomains?
1527
        elseif (! empty($options['subdomain'])) {
429✔
1528
            // If we don't match the current subdomain, then
1529
            // we don't need to add the route.
1530
            if (! $this->checkSubdomains($options['subdomain'])) {
237✔
1531
                return;
228✔
1532
            }
1533

1534
            $overwrite = true;
17✔
1535
        }
1536

1537
        // Are we offsetting the binds?
1538
        // If so, take care of them here in one
1539
        // fell swoop.
1540
        if (isset($options['offset']) && is_string($to)) {
427✔
1541
            // Get a constant string to work with.
1542
            $to = preg_replace('/(\$\d+)/', '$X', $to);
1✔
1543

1544
            for ($i = (int) $options['offset'] + 1; $i < (int) $options['offset'] + 7; $i++) {
1✔
1545
                $to = preg_replace_callback(
1✔
1546
                    '/\$X/',
1✔
1547
                    static fn ($m): string => '$' . $i,
1✔
1548
                    $to,
1✔
1549
                    1,
1✔
1550
                );
1✔
1551
            }
1552
        }
1553

1554
        $routeKey = $from;
427✔
1555

1556
        // Replace our regex pattern placeholders with the actual thing
1557
        // so that the Router doesn't need to know about any of this.
1558
        foreach ($this->placeholders as $tag => $pattern) {
427✔
1559
            $routeKey = str_ireplace(':' . $tag, $pattern, $routeKey);
427✔
1560
        }
1561

1562
        // If is redirect, No processing
1563
        if (! isset($options['redirect']) && is_string($to)) {
427✔
1564
            // If no namespace found, add the default namespace
1565
            if (! str_contains($to, '\\') || strpos($to, '\\') > 0) {
413✔
1566
                $namespace = $options['namespace'] ?? $this->defaultNamespace;
392✔
1567
                $to        = trim($namespace, '\\') . '\\' . $to;
392✔
1568
            }
1569
            // Always ensure that we escape our namespace so we're not pointing to
1570
            // \CodeIgniter\Routes\Controller::method.
1571
            $to = '\\' . ltrim($to, '\\');
413✔
1572
        }
1573

1574
        $name = $options['as'] ?? $routeKey;
427✔
1575

1576
        helper('array');
427✔
1577

1578
        // Don't overwrite any existing 'froms' so that auto-discovered routes
1579
        // do not overwrite any app/Config/Routes settings. The app
1580
        // routes should always be the "source of truth".
1581
        // this works only because discovered routes are added just prior
1582
        // to attempting to route the request.
1583
        $routeKeyExists = isset($this->routes[$verb][$routeKey]);
427✔
1584
        if ((isset($this->routesNames[$verb][$name]) || $routeKeyExists) && ! $overwrite) {
427✔
1585
            return;
10✔
1586
        }
1587

1588
        $this->routes[$verb][$routeKey] = [
427✔
1589
            'name'    => $name,
427✔
1590
            'handler' => $to,
427✔
1591
            'from'    => $from,
427✔
1592
        ];
427✔
1593
        $this->routesOptions[$verb][$routeKey] = $options;
427✔
1594
        $this->routesNames[$verb][$name]       = $routeKey;
427✔
1595

1596
        // Is this a redirect?
1597
        if (isset($options['redirect']) && is_numeric($options['redirect'])) {
427✔
1598
            $this->routes['*'][$routeKey]['redirect'] = $options['redirect'];
16✔
1599
        }
1600
    }
1601

1602
    /**
1603
     * Compares the hostname passed in against the current hostname
1604
     * on this page request.
1605
     *
1606
     * @param list<string>|string $hostname Hostname in route options
1607
     */
1608
    private function checkHostname($hostname): bool
1609
    {
1610
        // CLI calls can't be on hostname.
1611
        if (! isset($this->httpHost)) {
226✔
1612
            return false;
212✔
1613
        }
1614

1615
        // Has multiple hostnames
1616
        if (is_array($hostname)) {
14✔
1617
            $hostnameLower = array_map('strtolower', $hostname);
2✔
1618

1619
            return in_array(strtolower($this->httpHost), $hostnameLower, true);
2✔
1620
        }
1621

1622
        return strtolower($this->httpHost) === strtolower($hostname);
12✔
1623
    }
1624

1625
    private function processArrayCallableSyntax(string $from, array $to): string
1626
    {
1627
        // [classname, method]
1628
        // eg, [Home::class, 'index']
1629
        if (is_callable($to, true, $callableName)) {
3✔
1630
            // If the route has placeholders, add params automatically.
1631
            $params = $this->getMethodParams($from);
2✔
1632

1633
            return '\\' . $callableName . $params;
2✔
1634
        }
1635

1636
        // [[classname, method], params]
1637
        // eg, [[Home::class, 'index'], '$1/$2']
1638
        if (
1639
            isset($to[0], $to[1])
1✔
1640
            && is_callable($to[0], true, $callableName)
1✔
1641
            && is_string($to[1])
1✔
1642
        ) {
1643
            $to = '\\' . $callableName . '/' . $to[1];
1✔
1644
        }
1645

1646
        return $to;
1✔
1647
    }
1648

1649
    /**
1650
     * Returns the method param string like `/$1/$2` for placeholders
1651
     */
1652
    private function getMethodParams(string $from): string
1653
    {
1654
        preg_match_all('/\(.+?\)/', $from, $matches);
2✔
1655
        $count = count($matches[0]);
2✔
1656

1657
        $params = '';
2✔
1658

1659
        for ($i = 1; $i <= $count; $i++) {
2✔
1660
            $params .= '/$' . $i;
1✔
1661
        }
1662

1663
        return $params;
2✔
1664
    }
1665

1666
    /**
1667
     * Compares the subdomain(s) passed in against the current subdomain
1668
     * on this page request.
1669
     *
1670
     * @param list<string>|string $subdomains
1671
     */
1672
    private function checkSubdomains($subdomains): bool
1673
    {
1674
        // CLI calls can't be on subdomain.
1675
        if (! isset($this->httpHost)) {
237✔
1676
            return false;
212✔
1677
        }
1678

1679
        if ($this->currentSubdomain === null) {
25✔
1680
            $this->currentSubdomain = parse_subdomain($this->httpHost);
25✔
1681
        }
1682

1683
        if (! is_array($subdomains)) {
25✔
1684
            $subdomains = [$subdomains];
25✔
1685
        }
1686

1687
        // Routes can be limited to any sub-domain. In that case, though,
1688
        // it does require a sub-domain to be present.
1689
        if (! empty($this->currentSubdomain) && in_array('*', $subdomains, true)) {
25✔
1690
            return true;
10✔
1691
        }
1692

1693
        return in_array($this->currentSubdomain, $subdomains, true);
23✔
1694
    }
1695

1696
    /**
1697
     * Reset the routes, so that a test case can provide the
1698
     * explicit ones needed for it.
1699
     *
1700
     * @return void
1701
     */
1702
    public function resetRoutes()
1703
    {
1704
        $this->routes = $this->routesNames = ['*' => []];
46✔
1705

1706
        foreach ($this->defaultHTTPMethods as $verb) {
46✔
1707
            $this->routes[$verb]      = [];
46✔
1708
            $this->routesNames[$verb] = [];
46✔
1709
        }
1710

1711
        $this->routesOptions = [];
46✔
1712

1713
        $this->prioritizeDetected = false;
46✔
1714
        $this->didDiscover        = false;
46✔
1715
    }
1716

1717
    /**
1718
     * Load routes options based on verb
1719
     *
1720
     * @return array<
1721
     *     string,
1722
     *     array{
1723
     *         filter?: list<string>|string, namespace?: string, hostname?: string,
1724
     *         subdomain?: string, offset?: int, priority?: int, as?: string,
1725
     *         redirect?: int
1726
     *     }
1727
     * >
1728
     */
1729
    protected function loadRoutesOptions(?string $verb = null): array
1730
    {
1731
        $verb ??= $this->getHTTPVerb();
156✔
1732

1733
        $options = $this->routesOptions[$verb] ?? [];
156✔
1734

1735
        if (isset($this->routesOptions['*'])) {
156✔
1736
            foreach ($this->routesOptions['*'] as $key => $val) {
133✔
1737
                if (isset($options[$key])) {
133✔
1738
                    $extraOptions  = array_diff_key($val, $options[$key]);
8✔
1739
                    $options[$key] = array_merge($options[$key], $extraOptions);
8✔
1740
                } else {
1741
                    $options[$key] = $val;
127✔
1742
                }
1743
            }
1744
        }
1745

1746
        return $options;
156✔
1747
    }
1748

1749
    /**
1750
     * Enable or Disable sorting routes by priority
1751
     *
1752
     * @param bool $enabled The value status
1753
     *
1754
     * @return $this
1755
     */
1756
    public function setPrioritize(bool $enabled = true)
1757
    {
1758
        $this->prioritize = $enabled;
1✔
1759

1760
        return $this;
1✔
1761
    }
1762

1763
    /**
1764
     * Get all controllers in Route Handlers
1765
     *
1766
     * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`.
1767
     *                          `'*'` returns all controllers in any verb.
1768
     *
1769
     * @return list<string> controller name list
1770
     *
1771
     * @interal
1772
     */
1773
    public function getRegisteredControllers(?string $verb = '*'): array
1774
    {
1775
        if ($verb !== '*' && $verb === strtolower($verb)) {
12✔
UNCOV
1776
            @trigger_error(
×
UNCOV
1777
                'Passing lowercase HTTP method "' . $verb . '" is deprecated.'
×
UNCOV
1778
                . ' Use uppercase HTTP method like "' . strtoupper($verb) . '".',
×
UNCOV
1779
                E_USER_DEPRECATED,
×
UNCOV
1780
            );
×
1781
        }
1782

1783
        /**
1784
         * @deprecated 4.5.0
1785
         * @TODO Remove this in the future.
1786
         */
1787
        $verb = strtoupper($verb);
12✔
1788

1789
        $controllers = [];
12✔
1790

1791
        if ($verb === '*') {
12✔
1792
            foreach ($this->defaultHTTPMethods as $tmpVerb) {
8✔
1793
                foreach ($this->routes[$tmpVerb] as $route) {
8✔
1794
                    $controller = $this->getControllerName($route['handler']);
8✔
1795
                    if ($controller !== null) {
8✔
1796
                        $controllers[] = $controller;
4✔
1797
                    }
1798
                }
1799
            }
1800
        } else {
1801
            $routes = $this->getRoutes($verb);
4✔
1802

1803
            foreach ($routes as $handler) {
4✔
1804
                $controller = $this->getControllerName($handler);
4✔
1805
                if ($controller !== null) {
4✔
1806
                    $controllers[] = $controller;
4✔
1807
                }
1808
            }
1809
        }
1810

1811
        return array_unique($controllers);
12✔
1812
    }
1813

1814
    /**
1815
     * @param (Closure(mixed...): (ResponseInterface|string|void))|string $handler Handler
1816
     *
1817
     * @return string|null Controller classname
1818
     */
1819
    private function getControllerName($handler)
1820
    {
1821
        if (! is_string($handler)) {
12✔
1822
            return null;
6✔
1823
        }
1824

1825
        [$controller] = explode('::', $handler, 2);
8✔
1826

1827
        return $controller;
8✔
1828
    }
1829

1830
    /**
1831
     * Set The flag that limit or not the routes with {locale} placeholder to App::$supportedLocales
1832
     */
1833
    public function useSupportedLocalesOnly(bool $useOnly): self
1834
    {
1835
        $this->useSupportedLocalesOnly = $useOnly;
1✔
1836

1837
        return $this;
1✔
1838
    }
1839

1840
    /**
1841
     * Get the flag that limit or not the routes with {locale} placeholder to App::$supportedLocales
1842
     */
1843
    public function shouldUseSupportedLocalesOnly(): bool
1844
    {
1845
        return $this->useSupportedLocalesOnly;
2✔
1846
    }
1847
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc