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

codeigniter4 / CodeIgniter4 / 12518821104

27 Dec 2024 05:21PM UTC coverage: 84.426% (+0.02%) from 84.404%
12518821104

Pull #9339

github

web-flow
Merge 5caee6ae0 into 6cbbf601b
Pull Request #9339: refactor: enable instanceof and strictBooleans rector set

55 of 60 new or added lines in 34 files covered. (91.67%)

19 existing lines in 3 files now uncovered.

20437 of 24207 relevant lines covered (84.43%)

189.66 hits per line

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

91.7
/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\HTTP\Method;
19
use CodeIgniter\HTTP\ResponseInterface;
20
use CodeIgniter\Router\Exceptions\RouterException;
21
use Config\App;
22
use Config\Modules;
23
use Config\Routing;
24
use InvalidArgumentException;
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
     * A callable that will be shown
91
     * when the route cannot be matched.
92
     *
93
     * @var (Closure(string): (ResponseInterface|string|void))|string
94
     */
95
    protected $override404;
96

97
    /**
98
     * An array of files that would contain route definitions.
99
     */
100
    protected array $routeFiles = [];
101

102
    /**
103
     * Defined placeholders that can be used
104
     * within the
105
     *
106
     * @var array<string, string>
107
     */
108
    protected $placeholders = [
109
        'any'      => '.*',
110
        'segment'  => '[^/]+',
111
        'alphanum' => '[a-zA-Z0-9]+',
112
        'num'      => '[0-9]+',
113
        'alpha'    => '[a-zA-Z]+',
114
        'hash'     => '[^/]+',
115
    ];
116

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

155
    /**
156
     * Array of routes names
157
     *
158
     * @var array
159
     *
160
     * [
161
     *     verb => [
162
     *         routeName => routeKey(regex)
163
     *     ],
164
     * ]
165
     */
166
    protected $routesNames = [
167
        '*'             => [],
168
        Method::OPTIONS => [],
169
        Method::GET     => [],
170
        Method::HEAD    => [],
171
        Method::POST    => [],
172
        Method::PATCH   => [],
173
        Method::PUT     => [],
174
        Method::DELETE  => [],
175
        Method::TRACE   => [],
176
        Method::CONNECT => [],
177
        'CLI'           => [],
178
    ];
179

180
    /**
181
     * Array of routes options
182
     *
183
     * @var array
184
     *
185
     * [
186
     *     verb => [
187
     *         routeKey(regex) => [
188
     *             key => value,
189
     *         ]
190
     *     ],
191
     * ]
192
     */
193
    protected $routesOptions = [];
194

195
    /**
196
     * The current method that the script is being called by.
197
     *
198
     * @var string HTTP verb like `GET`,`POST` or `*` or `CLI`
199
     */
200
    protected $HTTPVerb = '*';
201

202
    /**
203
     * The default list of HTTP methods (and CLI for command line usage)
204
     * that is allowed if no other method is provided.
205
     *
206
     * @var list<string>
207
     */
208
    public $defaultHTTPMethods = Router::HTTP_METHODS;
209

210
    /**
211
     * The name of the current group, if any.
212
     *
213
     * @var string|null
214
     */
215
    protected $group;
216

217
    /**
218
     * The current subdomain.
219
     *
220
     * @var string|null
221
     */
222
    protected $currentSubdomain;
223

224
    /**
225
     * Stores copy of current options being
226
     * applied during creation.
227
     *
228
     * @var array|null
229
     */
230
    protected $currentOptions;
231

232
    /**
233
     * A little performance booster.
234
     *
235
     * @var bool
236
     */
237
    protected $didDiscover = false;
238

239
    /**
240
     * Handle to the file locator to use.
241
     *
242
     * @var FileLocatorInterface
243
     */
244
    protected $fileLocator;
245

246
    /**
247
     * Handle to the modules config.
248
     *
249
     * @var Modules
250
     */
251
    protected $moduleConfig;
252

253
    /**
254
     * Flag for sorting routes by priority.
255
     *
256
     * @var bool
257
     */
258
    protected $prioritize = false;
259

260
    /**
261
     * Route priority detection flag.
262
     *
263
     * @var bool
264
     */
265
    protected $prioritizeDetected = false;
266

267
    /**
268
     * The current hostname from $_SERVER['HTTP_HOST']
269
     */
270
    private ?string $httpHost = null;
271

272
    /**
273
     * Flag to limit or not the routes with {locale} placeholder to App::$supportedLocales
274
     */
275
    protected bool $useSupportedLocalesOnly = false;
276

277
    /**
278
     * Constructor
279
     */
280
    public function __construct(FileLocatorInterface $locator, Modules $moduleConfig, Routing $routing)
281
    {
282
        $this->fileLocator  = $locator;
470✔
283
        $this->moduleConfig = $moduleConfig;
470✔
284

285
        $this->httpHost = service('request')->getServer('HTTP_HOST');
470✔
286

287
        // Setup based on config file. Let routes file override.
288
        $this->defaultNamespace   = rtrim($routing->defaultNamespace, '\\') . '\\';
470✔
289
        $this->defaultController  = $routing->defaultController;
470✔
290
        $this->defaultMethod      = $routing->defaultMethod;
470✔
291
        $this->translateURIDashes = $routing->translateURIDashes;
470✔
292
        $this->override404        = $routing->override404;
470✔
293
        $this->autoRoute          = $routing->autoRoute;
470✔
294
        $this->routeFiles         = $routing->routeFiles;
470✔
295
        $this->prioritize         = $routing->prioritize;
470✔
296

297
        // Normalize the path string in routeFiles array.
298
        foreach ($this->routeFiles as $routeKey => $routesFile) {
470✔
299
            $realpath                    = realpath($routesFile);
470✔
300
            $this->routeFiles[$routeKey] = ($realpath === false) ? $routesFile : $realpath;
470✔
301
        }
302
    }
303

304
    /**
305
     * Loads main routes file and discover routes.
306
     *
307
     * Loads only once unless reset.
308
     *
309
     * @return $this
310
     */
311
    public function loadRoutes(string $routesFile = APPPATH . 'Config/Routes.php')
312
    {
313
        if ($this->didDiscover) {
165✔
314
            return $this;
24✔
315
        }
316

317
        // Normalize the path string in routesFile
318
        $realpath   = realpath($routesFile);
144✔
319
        $routesFile = ($realpath === false) ? $routesFile : $realpath;
144✔
320

321
        // Include the passed in routesFile if it doesn't exist.
322
        // Only keeping that around for BC purposes for now.
323
        $routeFiles = $this->routeFiles;
144✔
324
        if (! in_array($routesFile, $routeFiles, true)) {
144✔
325
            $routeFiles[] = $routesFile;
×
326
        }
327

328
        // We need this var in local scope
329
        // so route files can access it.
330
        $routes = $this;
144✔
331

332
        foreach ($routeFiles as $routesFile) {
144✔
333
            if (! is_file($routesFile)) {
144✔
334
                log_message('warning', sprintf('Routes file not found: "%s"', $routesFile));
×
335

336
                continue;
×
337
            }
338

339
            require $routesFile;
144✔
340
        }
341

342
        $this->discoverRoutes();
144✔
343

344
        return $this;
144✔
345
    }
346

347
    /**
348
     * Will attempt to discover any additional routes, either through
349
     * the local PSR4 namespaces, or through selected Composer packages.
350
     *
351
     * @return void
352
     */
353
    protected function discoverRoutes()
354
    {
355
        if ($this->didDiscover) {
347✔
356
            return;
64✔
357
        }
358

359
        // We need this var in local scope
360
        // so route files can access it.
361
        $routes = $this;
344✔
362

363
        if ($this->moduleConfig->shouldDiscover('routes')) {
344✔
364
            $files = $this->fileLocator->search('Config/Routes.php');
203✔
365

366
            foreach ($files as $file) {
203✔
367
                // Don't include our main file again...
368
                if (in_array($file, $this->routeFiles, true)) {
203✔
369
                    continue;
203✔
370
                }
371

372
                include $file;
203✔
373
            }
374
        }
375

376
        $this->didDiscover = true;
344✔
377
    }
378

379
    /**
380
     * Registers a new constraint with the system. Constraints are used
381
     * by the routes as placeholders for regular expressions to make defining
382
     * the routes more human-friendly.
383
     *
384
     * You can pass an associative array as $placeholder, and have
385
     * multiple placeholders added at once.
386
     *
387
     * @param array|string $placeholder
388
     */
389
    public function addPlaceholder($placeholder, ?string $pattern = null): RouteCollectionInterface
390
    {
391
        if (! is_array($placeholder)) {
5✔
392
            $placeholder = [$placeholder => $pattern];
4✔
393
        }
394

395
        $this->placeholders = array_merge($this->placeholders, $placeholder);
5✔
396

397
        return $this;
5✔
398
    }
399

400
    /**
401
     * For `spark routes`
402
     *
403
     * @return array<string, string>
404
     *
405
     * @internal
406
     */
407
    public function getPlaceholders(): array
408
    {
409
        return $this->placeholders;
15✔
410
    }
411

412
    /**
413
     * Sets the default namespace to use for Controllers when no other
414
     * namespace has been specified.
415
     */
416
    public function setDefaultNamespace(string $value): RouteCollectionInterface
417
    {
418
        $this->defaultNamespace = esc(strip_tags($value));
27✔
419
        $this->defaultNamespace = rtrim($this->defaultNamespace, '\\') . '\\';
27✔
420

421
        return $this;
27✔
422
    }
423

424
    /**
425
     * Sets the default controller to use when no other controller has been
426
     * specified.
427
     */
428
    public function setDefaultController(string $value): RouteCollectionInterface
429
    {
430
        $this->defaultController = esc(strip_tags($value));
10✔
431

432
        return $this;
10✔
433
    }
434

435
    /**
436
     * Sets the default method to call on the controller when no other
437
     * method has been set in the route.
438
     */
439
    public function setDefaultMethod(string $value): RouteCollectionInterface
440
    {
441
        $this->defaultMethod = esc(strip_tags($value));
7✔
442

443
        return $this;
7✔
444
    }
445

446
    /**
447
     * Tells the system whether to convert dashes in URI strings into
448
     * underscores. In some search engines, including Google, dashes
449
     * create more meaning and make it easier for the search engine to
450
     * find words and meaning in the URI for better SEO. But it
451
     * doesn't work well with PHP method names....
452
     */
453
    public function setTranslateURIDashes(bool $value): RouteCollectionInterface
454
    {
455
        $this->translateURIDashes = $value;
1✔
456

457
        return $this;
1✔
458
    }
459

460
    /**
461
     * If TRUE, the system will attempt to match the URI against
462
     * Controllers by matching each segment against folders/files
463
     * in APPPATH/Controllers, when a match wasn't found against
464
     * defined routes.
465
     *
466
     * If FALSE, will stop searching and do NO automatic routing.
467
     */
468
    public function setAutoRoute(bool $value): RouteCollectionInterface
469
    {
470
        $this->autoRoute = $value;
39✔
471

472
        return $this;
39✔
473
    }
474

475
    /**
476
     * Sets the class/method that should be called if routing doesn't
477
     * find a match. It can be either a closure or the controller/method
478
     * name exactly like a route is defined: Users::index
479
     *
480
     * This setting is passed to the Router class and handled there.
481
     *
482
     * @param callable|string|null $callable
483
     */
484
    public function set404Override($callable = null): RouteCollectionInterface
485
    {
486
        $this->override404 = $callable;
6✔
487

488
        return $this;
6✔
489
    }
490

491
    /**
492
     * Returns the 404 Override setting, which can be null, a closure
493
     * or the controller/string.
494
     *
495
     * @return (Closure(string): (ResponseInterface|string|void))|string|null
496
     */
497
    public function get404Override()
498
    {
499
        return $this->override404;
14✔
500
    }
501

502
    /**
503
     * Sets the default constraint to be used in the system. Typically
504
     * for use with the 'resource' method.
505
     */
506
    public function setDefaultConstraint(string $placeholder): RouteCollectionInterface
507
    {
508
        if (array_key_exists($placeholder, $this->placeholders)) {
2✔
509
            $this->defaultPlaceholder = $placeholder;
1✔
510
        }
511

512
        return $this;
2✔
513
    }
514

515
    /**
516
     * Returns the name of the default controller. With Namespace.
517
     */
518
    public function getDefaultController(): string
519
    {
520
        return $this->defaultController;
221✔
521
    }
522

523
    /**
524
     * Returns the name of the default method to use within the controller.
525
     */
526
    public function getDefaultMethod(): string
527
    {
528
        return $this->defaultMethod;
221✔
529
    }
530

531
    /**
532
     * Returns the default namespace as set in the Routes config file.
533
     */
534
    public function getDefaultNamespace(): string
535
    {
536
        return $this->defaultNamespace;
34✔
537
    }
538

539
    /**
540
     * Returns the current value of the translateURIDashes setting.
541
     */
542
    public function shouldTranslateURIDashes(): bool
543
    {
544
        return $this->translateURIDashes;
181✔
545
    }
546

547
    /**
548
     * Returns the flag that tells whether to autoRoute URI against Controllers.
549
     */
550
    public function shouldAutoRoute(): bool
551
    {
552
        return $this->autoRoute;
184✔
553
    }
554

555
    /**
556
     * Returns the raw array of available routes.
557
     *
558
     * @param non-empty-string|null $verb            HTTP verb like `GET`,`POST` or `*` or `CLI`.
559
     * @param bool                  $includeWildcard Whether to include '*' routes.
560
     */
561
    public function getRoutes(?string $verb = null, bool $includeWildcard = true): array
562
    {
563
        if ($verb === null || $verb === '') {
244✔
564
            $verb = $this->getHTTPVerb();
57✔
565
        }
566

567
        // Since this is the entry point for the Router,
568
        // take a moment to do any route discovery
569
        // we might need to do.
570
        $this->discoverRoutes();
244✔
571

572
        $routes = [];
244✔
573

574
        if (isset($this->routes[$verb])) {
244✔
575
            // Keep current verb's routes at the beginning, so they're matched
576
            // before any of the generic, "add" routes.
577
            $collection = $includeWildcard ? $this->routes[$verb] + ($this->routes['*'] ?? []) : $this->routes[$verb];
244✔
578

579
            foreach ($collection as $routeKey => $r) {
244✔
580
                $routes[$routeKey] = $r['handler'];
222✔
581
            }
582
        }
583

584
        // sorting routes by priority
585
        if ($this->prioritizeDetected && $this->prioritize && $routes !== []) {
244✔
586
            $order = [];
1✔
587

588
            foreach ($routes as $key => $value) {
1✔
589
                $key                    = $key === '/' ? $key : ltrim($key, '/ ');
1✔
590
                $priority               = $this->getRoutesOptions($key, $verb)['priority'] ?? 0;
1✔
591
                $order[$priority][$key] = $value;
1✔
592
            }
593

594
            ksort($order);
1✔
595
            $routes = array_merge(...$order);
1✔
596
        }
597

598
        return $routes;
244✔
599
    }
600

601
    /**
602
     * Returns one or all routes options
603
     *
604
     * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`.
605
     *
606
     * @return array<string, int|string> [key => value]
607
     */
608
    public function getRoutesOptions(?string $from = null, ?string $verb = null): array
609
    {
610
        $options = $this->loadRoutesOptions($verb);
134✔
611

612
        return $from !== null && $from !== '' && $from !== '0' ? $options[$from] ?? [] : $options;
134✔
613
    }
614

615
    /**
616
     * Returns the current HTTP Verb being used.
617
     */
618
    public function getHTTPVerb(): string
619
    {
620
        return $this->HTTPVerb;
252✔
621
    }
622

623
    /**
624
     * Sets the current HTTP verb.
625
     * Used primarily for testing.
626
     *
627
     * @param string $verb HTTP verb
628
     *
629
     * @return $this
630
     */
631
    public function setHTTPVerb(string $verb)
632
    {
633
        if ($verb !== '*' && $verb === strtolower($verb)) {
302✔
634
            @trigger_error(
×
635
                'Passing lowercase HTTP method "' . $verb . '" is deprecated.'
×
636
                . ' Use uppercase HTTP method like "' . strtoupper($verb) . '".',
×
637
                E_USER_DEPRECATED
×
638
            );
×
639
        }
640

641
        /**
642
         * @deprecated 4.5.0
643
         * @TODO Remove strtoupper() in the future.
644
         */
645
        $this->HTTPVerb = strtoupper($verb);
302✔
646

647
        return $this;
302✔
648
    }
649

650
    /**
651
     * A shortcut method to add a number of routes at a single time.
652
     * It does not allow any options to be set on the route, or to
653
     * define the method used.
654
     */
655
    public function map(array $routes = [], ?array $options = null): RouteCollectionInterface
656
    {
657
        foreach ($routes as $from => $to) {
67✔
658
            $this->add($from, $to, $options);
67✔
659
        }
660

661
        return $this;
67✔
662
    }
663

664
    /**
665
     * Adds a single route to the collection.
666
     *
667
     * Example:
668
     *      $routes->add('news', 'Posts::index');
669
     *
670
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
671
     */
672
    public function add(string $from, $to, ?array $options = null): RouteCollectionInterface
673
    {
674
        $this->create('*', $from, $to, $options);
333✔
675

676
        return $this;
333✔
677
    }
678

679
    /**
680
     * Adds a temporary redirect from one route to another. Used for
681
     * redirecting traffic from old, non-existing routes to the new
682
     * moved routes.
683
     *
684
     * @param string $from   The pattern to match against
685
     * @param string $to     Either a route name or a URI to redirect to
686
     * @param int    $status The HTTP status code that should be returned with this redirect
687
     *
688
     * @return RouteCollection
689
     */
690
    public function addRedirect(string $from, string $to, int $status = 302)
691
    {
692
        // Use the named route's pattern if this is a named route.
693
        if (array_key_exists($to, $this->routesNames['*'])) {
16✔
694
            $routeName  = $to;
3✔
695
            $routeKey   = $this->routesNames['*'][$routeName];
3✔
696
            $redirectTo = [$routeKey => $this->routes['*'][$routeKey]['handler']];
3✔
697
        } elseif (array_key_exists($to, $this->routesNames[Method::GET])) {
13✔
698
            $routeName  = $to;
4✔
699
            $routeKey   = $this->routesNames[Method::GET][$routeName];
4✔
700
            $redirectTo = [$routeKey => $this->routes[Method::GET][$routeKey]['handler']];
4✔
701
        } else {
702
            // The named route is not found.
703
            $redirectTo = $to;
9✔
704
        }
705

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

708
        return $this;
16✔
709
    }
710

711
    /**
712
     * Determines if the route is a redirecting route.
713
     *
714
     * @param string $routeKey routeKey or route name
715
     */
716
    public function isRedirect(string $routeKey): bool
717
    {
718
        if (isset($this->routes['*'][$routeKey]['redirect'])) {
140✔
719
            return true;
16✔
720
        }
721

722
        // This logic is not used. Should be deprecated?
723
        $routeName = $this->routes['*'][$routeKey]['name'] ?? null;
124✔
724
        if ($routeName === $routeKey) {
124✔
725
            $routeKey = $this->routesNames['*'][$routeName];
37✔
726

727
            return isset($this->routes['*'][$routeKey]['redirect']);
37✔
728
        }
729

730
        return false;
88✔
731
    }
732

733
    /**
734
     * Grabs the HTTP status code from a redirecting Route.
735
     *
736
     * @param string $routeKey routeKey or route name
737
     */
738
    public function getRedirectCode(string $routeKey): int
739
    {
740
        if (isset($this->routes['*'][$routeKey]['redirect'])) {
16✔
741
            return $this->routes['*'][$routeKey]['redirect'];
16✔
742
        }
743

744
        // This logic is not used. Should be deprecated?
745
        $routeName = $this->routes['*'][$routeKey]['name'] ?? null;
1✔
746
        if ($routeName === $routeKey) {
1✔
747
            $routeKey = $this->routesNames['*'][$routeName];
×
748

749
            return $this->routes['*'][$routeKey]['redirect'];
×
750
        }
751

752
        return 0;
1✔
753
    }
754

755
    /**
756
     * Group a series of routes under a single URL segment. This is handy
757
     * for grouping items into an admin area, like:
758
     *
759
     * Example:
760
     *     // Creates route: admin/users
761
     *     $route->group('admin', function() {
762
     *            $route->resource('users');
763
     *     });
764
     *
765
     * @param string         $name      The name to group/prefix the routes with.
766
     * @param array|callable ...$params
767
     *
768
     * @return void
769
     */
770
    public function group(string $name, ...$params)
771
    {
772
        $oldGroup   = $this->group;
18✔
773
        $oldOptions = $this->currentOptions;
18✔
774

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

780
        $callback = array_pop($params);
18✔
781

782
        if ($params && is_array($params[0])) {
18✔
783
            $options = array_shift($params);
9✔
784

785
            if (isset($options['filter'])) {
9✔
786
                // Merge filters.
787
                $currentFilter     = (array) ($this->currentOptions['filter'] ?? []);
8✔
788
                $options['filter'] = array_merge($currentFilter, (array) $options['filter']);
8✔
789
            }
790

791
            // Merge options other than filters.
792
            $this->currentOptions = array_merge(
9✔
793
                $this->currentOptions ?? [],
9✔
794
                $options
9✔
795
            );
9✔
796
        }
797

798
        if (is_callable($callback)) {
18✔
799
            $callback($this);
18✔
800
        }
801

802
        $this->group          = $oldGroup;
18✔
803
        $this->currentOptions = $oldOptions;
18✔
804
    }
805

806
    /*
807
     * --------------------------------------------------------------------
808
     *  HTTP Verb-based routing
809
     * --------------------------------------------------------------------
810
     * Routing works here because, as the routes Config file is read in,
811
     * the various HTTP verb-based routes will only be added to the in-memory
812
     * routes if it is a call that should respond to that verb.
813
     *
814
     * The options array is typically used to pass in an 'as' or var, but may
815
     * be expanded in the future. See the docblock for 'add' method above for
816
     * current list of globally available options.
817
     */
818

819
    /**
820
     * Creates a collections of HTTP-verb based routes for a controller.
821
     *
822
     * Possible Options:
823
     *      'controller'    - Customize the name of the controller used in the 'to' route
824
     *      'placeholder'   - The regex used by the Router. Defaults to '(:any)'
825
     *      'websafe'   -        - '1' if only GET and POST HTTP verbs are supported
826
     *
827
     * Example:
828
     *
829
     *      $route->resource('photos');
830
     *
831
     *      // Generates the following routes:
832
     *      HTTP Verb | Path        | Action        | Used for...
833
     *      ----------+-------------+---------------+-----------------
834
     *      GET         /photos             index           an array of photo objects
835
     *      GET         /photos/new         new             an empty photo object, with default properties
836
     *      GET         /photos/{id}/edit   edit            a specific photo object, editable properties
837
     *      GET         /photos/{id}        show            a specific photo object, all properties
838
     *      POST        /photos             create          a new photo object, to add to the resource
839
     *      DELETE      /photos/{id}        delete          deletes the specified photo object
840
     *      PUT/PATCH   /photos/{id}        update          replacement properties for existing photo
841
     *
842
     *  If 'websafe' option is present, the following paths are also available:
843
     *
844
     *      POST                /photos/{id}/delete delete
845
     *      POST        /photos/{id}        update
846
     *
847
     * @param string     $name    The name of the resource/controller to route to.
848
     * @param array|null $options A list of possible ways to customize the routing.
849
     */
850
    public function resource(string $name, ?array $options = null): RouteCollectionInterface
851
    {
852
        // In order to allow customization of the route the
853
        // resources are sent to, we need to have a new name
854
        // to store the values in.
855
        $newName = implode('\\', array_map(ucfirst(...), explode('/', $name)));
18✔
856

857
        // If a new controller is specified, then we replace the
858
        // $name value with the name of the new controller.
859
        if (isset($options['controller'])) {
18✔
860
            $newName = ucfirst(esc(strip_tags($options['controller'])));
11✔
861
        }
862

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

867
        // Make sure we capture back-references
868
        $id = '(' . trim($id, '()') . ')';
18✔
869

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

872
        if (isset($options['except'])) {
18✔
873
            $options['except'] = is_array($options['except']) ? $options['except'] : explode(',', $options['except']);
1✔
874

875
            foreach ($methods as $i => $method) {
1✔
876
                if (in_array($method, $options['except'], true)) {
1✔
877
                    unset($methods[$i]);
1✔
878
                }
879
            }
880
        }
881

882
        if (in_array('index', $methods, true)) {
18✔
883
            $this->get($name, $newName . '::index', $options);
18✔
884
        }
885
        if (in_array('new', $methods, true)) {
18✔
886
            $this->get($name . '/new', $newName . '::new', $options);
16✔
887
        }
888
        if (in_array('edit', $methods, true)) {
18✔
889
            $this->get($name . '/' . $id . '/edit', $newName . '::edit/$1', $options);
16✔
890
        }
891
        if (in_array('show', $methods, true)) {
18✔
892
            $this->get($name . '/' . $id, $newName . '::show/$1', $options);
17✔
893
        }
894
        if (in_array('create', $methods, true)) {
18✔
895
            $this->post($name, $newName . '::create', $options);
17✔
896
        }
897
        if (in_array('update', $methods, true)) {
18✔
898
            $this->put($name . '/' . $id, $newName . '::update/$1', $options);
17✔
899
            $this->patch($name . '/' . $id, $newName . '::update/$1', $options);
17✔
900
        }
901
        if (in_array('delete', $methods, true)) {
18✔
902
            $this->delete($name . '/' . $id, $newName . '::delete/$1', $options);
17✔
903
        }
904

905
        // Web Safe? delete needs checking before update because of method name
906
        if (isset($options['websafe'])) {
18✔
907
            if (in_array('delete', $methods, true)) {
1✔
908
                $this->post($name . '/' . $id . '/delete', $newName . '::delete/$1', $options);
1✔
909
            }
910
            if (in_array('update', $methods, true)) {
1✔
911
                $this->post($name . '/' . $id, $newName . '::update/$1', $options);
1✔
912
            }
913
        }
914

915
        return $this;
18✔
916
    }
917

918
    /**
919
     * Creates a collections of HTTP-verb based routes for a presenter controller.
920
     *
921
     * Possible Options:
922
     *      'controller'    - Customize the name of the controller used in the 'to' route
923
     *      'placeholder'   - The regex used by the Router. Defaults to '(:any)'
924
     *
925
     * Example:
926
     *
927
     *      $route->presenter('photos');
928
     *
929
     *      // Generates the following routes:
930
     *      HTTP Verb | Path        | Action        | Used for...
931
     *      ----------+-------------+---------------+-----------------
932
     *      GET         /photos             index           showing all array of photo objects
933
     *      GET         /photos/show/{id}   show            showing a specific photo object, all properties
934
     *      GET         /photos/new         new             showing a form for an empty photo object, with default properties
935
     *      POST        /photos/create      create          processing the form for a new photo
936
     *      GET         /photos/edit/{id}   edit            show an editing form for a specific photo object, editable properties
937
     *      POST        /photos/update/{id} update          process the editing form data
938
     *      GET         /photos/remove/{id} remove          show a form to confirm deletion of a specific photo object
939
     *      POST        /photos/delete/{id} delete          deleting the specified photo object
940
     *
941
     * @param string     $name    The name of the controller to route to.
942
     * @param array|null $options A list of possible ways to customize the routing.
943
     */
944
    public function presenter(string $name, ?array $options = null): RouteCollectionInterface
945
    {
946
        // In order to allow customization of the route the
947
        // resources are sent to, we need to have a new name
948
        // to store the values in.
949
        $newName = implode('\\', array_map(ucfirst(...), explode('/', $name)));
9✔
950

951
        // If a new controller is specified, then we replace the
952
        // $name value with the name of the new controller.
953
        if (isset($options['controller'])) {
9✔
954
            $newName = ucfirst(esc(strip_tags($options['controller'])));
8✔
955
        }
956

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

961
        // Make sure we capture back-references
962
        $id = '(' . trim($id, '()') . ')';
9✔
963

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

966
        if (isset($options['except'])) {
9✔
967
            $options['except'] = is_array($options['except']) ? $options['except'] : explode(',', $options['except']);
×
968

969
            foreach ($methods as $i => $method) {
×
970
                if (in_array($method, $options['except'], true)) {
×
971
                    unset($methods[$i]);
×
972
                }
973
            }
974
        }
975

976
        if (in_array('index', $methods, true)) {
9✔
977
            $this->get($name, $newName . '::index', $options);
9✔
978
        }
979
        if (in_array('show', $methods, true)) {
9✔
980
            $this->get($name . '/show/' . $id, $newName . '::show/$1', $options);
9✔
981
        }
982
        if (in_array('new', $methods, true)) {
9✔
983
            $this->get($name . '/new', $newName . '::new', $options);
9✔
984
        }
985
        if (in_array('create', $methods, true)) {
9✔
986
            $this->post($name . '/create', $newName . '::create', $options);
9✔
987
        }
988
        if (in_array('edit', $methods, true)) {
9✔
989
            $this->get($name . '/edit/' . $id, $newName . '::edit/$1', $options);
9✔
990
        }
991
        if (in_array('update', $methods, true)) {
9✔
992
            $this->post($name . '/update/' . $id, $newName . '::update/$1', $options);
9✔
993
        }
994
        if (in_array('remove', $methods, true)) {
9✔
995
            $this->get($name . '/remove/' . $id, $newName . '::remove/$1', $options);
9✔
996
        }
997
        if (in_array('delete', $methods, true)) {
9✔
998
            $this->post($name . '/delete/' . $id, $newName . '::delete/$1', $options);
9✔
999
        }
1000
        if (in_array('show', $methods, true)) {
9✔
1001
            $this->get($name . '/' . $id, $newName . '::show/$1', $options);
9✔
1002
        }
1003
        if (in_array('create', $methods, true)) {
9✔
1004
            $this->post($name, $newName . '::create', $options);
9✔
1005
        }
1006

1007
        return $this;
9✔
1008
    }
1009

1010
    /**
1011
     * Specifies a single route to match for multiple HTTP Verbs.
1012
     *
1013
     * Example:
1014
     *  $route->match( ['GET', 'POST'], 'users/(:num)', 'users/$1);
1015
     *
1016
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1017
     */
1018
    public function match(array $verbs = [], string $from = '', $to = '', ?array $options = null): RouteCollectionInterface
1019
    {
1020
        if ($from === '' || empty($to)) {
4✔
1021
            throw new InvalidArgumentException('You must supply the parameters: from, to.');
×
1022
        }
1023

1024
        foreach ($verbs as $verb) {
4✔
1025
            if ($verb === strtolower($verb)) {
4✔
1026
                @trigger_error(
×
1027
                    'Passing lowercase HTTP method "' . $verb . '" is deprecated.'
×
1028
                    . ' Use uppercase HTTP method like "' . strtoupper($verb) . '".',
×
1029
                    E_USER_DEPRECATED
×
1030
                );
×
1031
            }
1032

1033
            /**
1034
             * @TODO We should use correct uppercase verb.
1035
             * @deprecated 4.5.0
1036
             */
1037
            $verb = strtolower($verb);
4✔
1038

1039
            $this->{$verb}($from, $to, $options);
4✔
1040
        }
1041

1042
        return $this;
4✔
1043
    }
1044

1045
    /**
1046
     * Specifies a route that is only available to GET requests.
1047
     *
1048
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1049
     */
1050
    public function get(string $from, $to, ?array $options = null): RouteCollectionInterface
1051
    {
1052
        $this->create(Method::GET, $from, $to, $options);
280✔
1053

1054
        return $this;
280✔
1055
    }
1056

1057
    /**
1058
     * Specifies a route that is only available to POST requests.
1059
     *
1060
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1061
     */
1062
    public function post(string $from, $to, ?array $options = null): RouteCollectionInterface
1063
    {
1064
        $this->create(Method::POST, $from, $to, $options);
47✔
1065

1066
        return $this;
47✔
1067
    }
1068

1069
    /**
1070
     * Specifies a route that is only available to PUT requests.
1071
     *
1072
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1073
     */
1074
    public function put(string $from, $to, ?array $options = null): RouteCollectionInterface
1075
    {
1076
        $this->create(Method::PUT, $from, $to, $options);
25✔
1077

1078
        return $this;
25✔
1079
    }
1080

1081
    /**
1082
     * Specifies a route that is only available to DELETE requests.
1083
     *
1084
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1085
     */
1086
    public function delete(string $from, $to, ?array $options = null): RouteCollectionInterface
1087
    {
1088
        $this->create(Method::DELETE, $from, $to, $options);
19✔
1089

1090
        return $this;
19✔
1091
    }
1092

1093
    /**
1094
     * Specifies a route that is only available to HEAD requests.
1095
     *
1096
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1097
     */
1098
    public function head(string $from, $to, ?array $options = null): RouteCollectionInterface
1099
    {
1100
        $this->create(Method::HEAD, $from, $to, $options);
1✔
1101

1102
        return $this;
1✔
1103
    }
1104

1105
    /**
1106
     * Specifies a route that is only available to PATCH requests.
1107
     *
1108
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1109
     */
1110
    public function patch(string $from, $to, ?array $options = null): RouteCollectionInterface
1111
    {
1112
        $this->create(Method::PATCH, $from, $to, $options);
19✔
1113

1114
        return $this;
19✔
1115
    }
1116

1117
    /**
1118
     * Specifies a route that is only available to OPTIONS requests.
1119
     *
1120
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1121
     */
1122
    public function options(string $from, $to, ?array $options = null): RouteCollectionInterface
1123
    {
1124
        $this->create(Method::OPTIONS, $from, $to, $options);
3✔
1125

1126
        return $this;
3✔
1127
    }
1128

1129
    /**
1130
     * Specifies a route that is only available to command-line requests.
1131
     *
1132
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1133
     */
1134
    public function cli(string $from, $to, ?array $options = null): RouteCollectionInterface
1135
    {
1136
        $this->create('CLI', $from, $to, $options);
7✔
1137

1138
        return $this;
7✔
1139
    }
1140

1141
    /**
1142
     * Specifies a route that will only display a view.
1143
     * Only works for GET requests.
1144
     */
1145
    public function view(string $from, string $view, ?array $options = null): RouteCollectionInterface
1146
    {
1147
        $to = static fn (...$data) => service('renderer')
2✔
1148
            ->setData(['segments' => $data], 'raw')
2✔
1149
            ->render($view, $options);
2✔
1150

1151
        $routeOptions = $options ?? [];
2✔
1152
        $routeOptions = array_merge($routeOptions, ['view' => $view]);
2✔
1153

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

1156
        return $this;
2✔
1157
    }
1158

1159
    /**
1160
     * Limits the routes to a specified ENVIRONMENT or they won't run.
1161
     *
1162
     * @param Closure(RouteCollection): void $callback
1163
     */
1164
    public function environment(string $env, Closure $callback): RouteCollectionInterface
1165
    {
1166
        if ($env === ENVIRONMENT) {
1✔
1167
            $callback($this);
1✔
1168
        }
1169

1170
        return $this;
1✔
1171
    }
1172

1173
    /**
1174
     * Attempts to look up a route based on its destination.
1175
     *
1176
     * If a route exists:
1177
     *
1178
     *      'path/(:any)/(:any)' => 'Controller::method/$1/$2'
1179
     *
1180
     * This method allows you to know the Controller and method
1181
     * and get the route that leads to it.
1182
     *
1183
     *      // Equals 'path/$param1/$param2'
1184
     *      reverseRoute('Controller::method', $param1, $param2);
1185
     *
1186
     * @param string     $search    Route name or Controller::method
1187
     * @param int|string ...$params One or more parameters to be passed to the route.
1188
     *                              The last parameter allows you to set the locale.
1189
     *
1190
     * @return false|string The route (URI path relative to baseURL) or false if not found.
1191
     */
1192
    public function reverseRoute(string $search, ...$params)
1193
    {
1194
        if ($search === '') {
52✔
1195
            return false;
2✔
1196
        }
1197

1198
        // Named routes get higher priority.
1199
        foreach ($this->routesNames as $verb => $collection) {
50✔
1200
            if (array_key_exists($search, $collection)) {
50✔
1201
                $routeKey = $collection[$search];
26✔
1202

1203
                $from = $this->routes[$verb][$routeKey]['from'];
26✔
1204

1205
                return $this->buildReverseRoute($from, $params);
26✔
1206
            }
1207
        }
1208

1209
        // Add the default namespace if needed.
1210
        $namespace = trim($this->defaultNamespace, '\\') . '\\';
24✔
1211
        if (
1212
            ! str_starts_with($search, '\\')
24✔
1213
            && ! str_starts_with($search, $namespace)
24✔
1214
        ) {
1215
            $search = $namespace . $search;
22✔
1216
        }
1217

1218
        // If it's not a named route, then loop over
1219
        // all routes to find a match.
1220
        foreach ($this->routes as $collection) {
24✔
1221
            foreach ($collection as $route) {
24✔
1222
                $to   = $route['handler'];
19✔
1223
                $from = $route['from'];
19✔
1224

1225
                // ignore closures
1226
                if (! is_string($to)) {
19✔
1227
                    continue;
3✔
1228
                }
1229

1230
                // Lose any namespace slash at beginning of strings
1231
                // to ensure more consistent match.
1232
                $to     = ltrim($to, '\\');
18✔
1233
                $search = ltrim($search, '\\');
18✔
1234

1235
                // If there's any chance of a match, then it will
1236
                // be with $search at the beginning of the $to string.
1237
                if (! str_starts_with($to, $search)) {
18✔
1238
                    continue;
6✔
1239
                }
1240

1241
                // Ensure that the number of $params given here
1242
                // matches the number of back-references in the route
1243
                if (substr_count($to, '$') !== count($params)) {
14✔
1244
                    continue;
1✔
1245
                }
1246

1247
                return $this->buildReverseRoute($from, $params);
13✔
1248
            }
1249
        }
1250

1251
        // If we're still here, then we did not find a match.
1252
        return false;
11✔
1253
    }
1254

1255
    /**
1256
     * Replaces the {locale} tag with the current application locale
1257
     *
1258
     * @deprecated Unused.
1259
     */
1260
    protected function localizeRoute(string $route): string
1261
    {
1262
        return strtr($route, ['{locale}' => service('request')->getLocale()]);
×
1263
    }
1264

1265
    /**
1266
     * Checks a route (using the "from") to see if it's filtered or not.
1267
     *
1268
     * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`.
1269
     */
1270
    public function isFiltered(string $search, ?string $verb = null): bool
1271
    {
1272
        $options = $this->loadRoutesOptions($verb);
123✔
1273

1274
        return isset($options[$search]['filter']);
123✔
1275
    }
1276

1277
    /**
1278
     * Returns the filters that should be applied for a single route, along
1279
     * with any parameters it might have. Parameters are found by splitting
1280
     * the parameter name on a colon to separate the filter name from the parameter list,
1281
     * and the splitting the result on commas. So:
1282
     *
1283
     *    'role:admin,manager'
1284
     *
1285
     * has a filter of "role", with parameters of ['admin', 'manager'].
1286
     *
1287
     * @param string      $search routeKey
1288
     * @param string|null $verb   HTTP verb like `GET`,`POST` or `*` or `CLI`.
1289
     *
1290
     * @return list<string> filter_name or filter_name:arguments like 'role:admin,manager'
1291
     */
1292
    public function getFiltersForRoute(string $search, ?string $verb = null): array
1293
    {
1294
        $options = $this->loadRoutesOptions($verb);
20✔
1295

1296
        if (! array_key_exists($search, $options) || ! array_key_exists('filter', $options[$search])) {
20✔
1297
            return [];
6✔
1298
        }
1299

1300
        if (is_string($options[$search]['filter'])) {
15✔
1301
            return [$options[$search]['filter']];
×
1302
        }
1303

1304
        return $options[$search]['filter'];
15✔
1305
    }
1306

1307
    /**
1308
     * Given a
1309
     *
1310
     * @throws RouterException
1311
     *
1312
     * @deprecated Unused. Now uses buildReverseRoute().
1313
     */
1314
    protected function fillRouteParams(string $from, ?array $params = null): string
1315
    {
1316
        // Find all of our back-references in the original route
1317
        preg_match_all('/\(([^)]+)\)/', $from, $matches);
×
1318

1319
        if (empty($matches[0])) {
×
1320
            return '/' . ltrim($from, '/');
×
1321
        }
1322

1323
        /**
1324
         * Build our resulting string, inserting the $params in
1325
         * the appropriate places.
1326
         *
1327
         * @var list<string> $patterns
1328
         */
1329
        $patterns = $matches[0];
×
1330

1331
        foreach ($patterns as $index => $pattern) {
×
NEW
1332
            if (preg_match('#^' . $pattern . '$#u', $params[$index]) !== 1) {
×
1333
                throw RouterException::forInvalidParameterType();
×
1334
            }
1335

1336
            // Ensure that the param we're inserting matches
1337
            // the expected param type.
1338
            $pos  = strpos($from, $pattern);
×
1339
            $from = substr_replace($from, $params[$index], $pos, strlen($pattern));
×
1340
        }
1341

1342
        return '/' . ltrim($from, '/');
×
1343
    }
1344

1345
    /**
1346
     * Builds reverse route
1347
     *
1348
     * @param array $params One or more parameters to be passed to the route.
1349
     *                      The last parameter allows you to set the locale.
1350
     */
1351
    protected function buildReverseRoute(string $from, array $params): string
1352
    {
1353
        $locale = null;
39✔
1354

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

1358
        if (empty($matches[0])) {
39✔
1359
            if (str_contains($from, '{locale}')) {
12✔
1360
                $locale = $params[0] ?? null;
3✔
1361
            }
1362

1363
            $from = $this->replaceLocale($from, $locale);
12✔
1364

1365
            return '/' . ltrim($from, '/');
12✔
1366
        }
1367

1368
        // Locale is passed?
1369
        $placeholderCount = count($matches[0]);
27✔
1370
        if (count($params) > $placeholderCount) {
27✔
1371
            $locale = $params[$placeholderCount];
3✔
1372
        }
1373

1374
        /**
1375
         * Build our resulting string, inserting the $params in
1376
         * the appropriate places.
1377
         *
1378
         * @var list<string> $placeholders
1379
         */
1380
        $placeholders = $matches[0];
27✔
1381

1382
        foreach ($placeholders as $index => $placeholder) {
27✔
1383
            if (! isset($params[$index])) {
27✔
1384
                throw new InvalidArgumentException(
1✔
1385
                    'Missing argument for "' . $placeholder . '" in route "' . $from . '".'
1✔
1386
                );
1✔
1387
            }
1388

1389
            // Remove `(:` and `)` when $placeholder is a placeholder.
1390
            $placeholderName = substr($placeholder, 2, -1);
26✔
1391
            // or maybe $placeholder is not a placeholder, but a regex.
1392
            $pattern = $this->placeholders[$placeholderName] ?? $placeholder;
26✔
1393

1394
            if (preg_match('#^' . $pattern . '$#u', (string) $params[$index]) !== 1) {
26✔
1395
                throw RouterException::forInvalidParameterType();
1✔
1396
            }
1397

1398
            // Ensure that the param we're inserting matches
1399
            // the expected param type.
1400
            $pos  = strpos($from, $placeholder);
26✔
1401
            $from = substr_replace($from, (string) $params[$index], $pos, strlen($placeholder));
26✔
1402
        }
1403

1404
        $from = $this->replaceLocale($from, $locale);
25✔
1405

1406
        return '/' . ltrim($from, '/');
25✔
1407
    }
1408

1409
    /**
1410
     * Replaces the {locale} tag with the locale
1411
     */
1412
    private function replaceLocale(string $route, ?string $locale = null): string
1413
    {
1414
        if (! str_contains($route, '{locale}')) {
37✔
1415
            return $route;
28✔
1416
        }
1417

1418
        // Check invalid locale
1419
        if ($locale !== null) {
9✔
1420
            $config = config(App::class);
3✔
1421
            if (! in_array($locale, $config->supportedLocales, true)) {
3✔
1422
                $locale = null;
1✔
1423
            }
1424
        }
1425

1426
        if ($locale === null) {
9✔
1427
            $locale = service('request')->getLocale();
7✔
1428
        }
1429

1430
        return strtr($route, ['{locale}' => $locale]);
9✔
1431
    }
1432

1433
    /**
1434
     * Does the heavy lifting of creating an actual route. You must specify
1435
     * the request method(s) that this route will work for. They can be separated
1436
     * by a pipe character "|" if there is more than one.
1437
     *
1438
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1439
     *
1440
     * @return void
1441
     */
1442
    protected function create(string $verb, string $from, $to, ?array $options = null)
1443
    {
1444
        $overwrite = false;
404✔
1445
        $prefix    = $this->group === null ? '' : $this->group . '/';
404✔
1446

1447
        $from = esc(strip_tags($prefix . $from));
404✔
1448

1449
        // While we want to add a route within a group of '/',
1450
        // it doesn't work with matching, so remove them...
1451
        if ($from !== '/') {
404✔
1452
            $from = trim($from, '/');
397✔
1453
        }
1454

1455
        // When redirecting to named route, $to is an array like `['zombies' => '\Zombies::index']`.
1456
        if (is_array($to) && isset($to[0])) {
404✔
1457
            $to = $this->processArrayCallableSyntax($from, $to);
3✔
1458
        }
1459

1460
        // Merge group filters.
1461
        if (isset($options['filter'])) {
404✔
1462
            $currentFilter     = (array) ($this->currentOptions['filter'] ?? []);
12✔
1463
            $options['filter'] = array_merge($currentFilter, (array) $options['filter']);
12✔
1464
        }
1465

1466
        $options = array_merge($this->currentOptions ?? [], $options ?? []);
404✔
1467

1468
        // Route priority detect
1469
        if (isset($options['priority'])) {
404✔
1470
            $options['priority'] = abs((int) $options['priority']);
3✔
1471

1472
            if ($options['priority'] > 0) {
3✔
1473
                $this->prioritizeDetected = true;
3✔
1474
            }
1475
        }
1476

1477
        // Hostname limiting?
1478
        if (! empty($options['hostname'])) {
404✔
1479
            // @todo determine if there's a way to whitelist hosts?
1480
            if (! $this->checkHostname($options['hostname'])) {
208✔
1481
                return;
205✔
1482
            }
1483

1484
            $overwrite = true;
4✔
1485
        }
1486
        // Limiting to subdomains?
1487
        elseif (! empty($options['subdomain'])) {
400✔
1488
            // If we don't match the current subdomain, then
1489
            // we don't need to add the route.
1490
            if (! $this->checkSubdomains($options['subdomain'])) {
221✔
1491
                return;
212✔
1492
            }
1493

1494
            $overwrite = true;
16✔
1495
        }
1496

1497
        // Are we offsetting the binds?
1498
        // If so, take care of them here in one
1499
        // fell swoop.
1500
        if (isset($options['offset']) && is_string($to)) {
398✔
1501
            // Get a constant string to work with.
1502
            $to = preg_replace('/(\$\d+)/', '$X', $to);
1✔
1503

1504
            for ($i = (int) $options['offset'] + 1; $i < (int) $options['offset'] + 7; $i++) {
1✔
1505
                $to = preg_replace_callback(
1✔
1506
                    '/\$X/',
1✔
1507
                    static fn ($m) => '$' . $i,
1✔
1508
                    $to,
1✔
1509
                    1
1✔
1510
                );
1✔
1511
            }
1512
        }
1513

1514
        $routeKey = $from;
398✔
1515

1516
        // Replace our regex pattern placeholders with the actual thing
1517
        // so that the Router doesn't need to know about any of this.
1518
        foreach ($this->placeholders as $tag => $pattern) {
398✔
1519
            $routeKey = str_ireplace(':' . $tag, $pattern, $routeKey);
398✔
1520
        }
1521

1522
        // If is redirect, No processing
1523
        if (! isset($options['redirect']) && is_string($to)) {
398✔
1524
            // If no namespace found, add the default namespace
1525
            if (! str_contains($to, '\\') || strpos($to, '\\') > 0) {
384✔
1526
                $namespace = $options['namespace'] ?? $this->defaultNamespace;
363✔
1527
                $to        = trim($namespace, '\\') . '\\' . $to;
363✔
1528
            }
1529
            // Always ensure that we escape our namespace so we're not pointing to
1530
            // \CodeIgniter\Routes\Controller::method.
1531
            $to = '\\' . ltrim($to, '\\');
384✔
1532
        }
1533

1534
        $name = $options['as'] ?? $routeKey;
398✔
1535

1536
        helper('array');
398✔
1537

1538
        // Don't overwrite any existing 'froms' so that auto-discovered routes
1539
        // do not overwrite any app/Config/Routes settings. The app
1540
        // routes should always be the "source of truth".
1541
        // this works only because discovered routes are added just prior
1542
        // to attempting to route the request.
1543
        $routeKeyExists = isset($this->routes[$verb][$routeKey]);
398✔
1544
        if ((isset($this->routesNames[$verb][$name]) || $routeKeyExists) && ! $overwrite) {
398✔
1545
            return;
10✔
1546
        }
1547

1548
        $this->routes[$verb][$routeKey] = [
398✔
1549
            'name'    => $name,
398✔
1550
            'handler' => $to,
398✔
1551
            'from'    => $from,
398✔
1552
        ];
398✔
1553
        $this->routesOptions[$verb][$routeKey] = $options;
398✔
1554
        $this->routesNames[$verb][$name]       = $routeKey;
398✔
1555

1556
        // Is this a redirect?
1557
        if (isset($options['redirect']) && is_numeric($options['redirect'])) {
398✔
1558
            $this->routes['*'][$routeKey]['redirect'] = $options['redirect'];
16✔
1559
        }
1560
    }
1561

1562
    /**
1563
     * Compares the hostname passed in against the current hostname
1564
     * on this page request.
1565
     *
1566
     * @param string $hostname Hostname in route options
1567
     */
1568
    private function checkHostname($hostname): bool
1569
    {
1570
        // CLI calls can't be on hostname.
1571
        if (! isset($this->httpHost)) {
208✔
1572
            return false;
196✔
1573
        }
1574

1575
        return strtolower($this->httpHost) === strtolower($hostname);
12✔
1576
    }
1577

1578
    private function processArrayCallableSyntax(string $from, array $to): string
1579
    {
1580
        // [classname, method]
1581
        // eg, [Home::class, 'index']
1582
        if (is_callable($to, true, $callableName)) {
3✔
1583
            // If the route has placeholders, add params automatically.
1584
            $params = $this->getMethodParams($from);
2✔
1585

1586
            return '\\' . $callableName . $params;
2✔
1587
        }
1588

1589
        // [[classname, method], params]
1590
        // eg, [[Home::class, 'index'], '$1/$2']
1591
        if (
1592
            isset($to[0], $to[1])
1✔
1593
            && is_callable($to[0], true, $callableName)
1✔
1594
            && is_string($to[1])
1✔
1595
        ) {
1596
            $to = '\\' . $callableName . '/' . $to[1];
1✔
1597
        }
1598

1599
        return $to;
1✔
1600
    }
1601

1602
    /**
1603
     * Returns the method param string like `/$1/$2` for placeholders
1604
     */
1605
    private function getMethodParams(string $from): string
1606
    {
1607
        preg_match_all('/\(.+?\)/', $from, $matches);
2✔
1608
        $count = count($matches[0]);
2✔
1609

1610
        $params = '';
2✔
1611

1612
        for ($i = 1; $i <= $count; $i++) {
2✔
1613
            $params .= '/$' . $i;
1✔
1614
        }
1615

1616
        return $params;
2✔
1617
    }
1618

1619
    /**
1620
     * Compares the subdomain(s) passed in against the current subdomain
1621
     * on this page request.
1622
     *
1623
     * @param list<string>|string $subdomains
1624
     */
1625
    private function checkSubdomains($subdomains): bool
1626
    {
1627
        // CLI calls can't be on subdomain.
1628
        if (! isset($this->httpHost)) {
221✔
1629
            return false;
196✔
1630
        }
1631

1632
        if ($this->currentSubdomain === null) {
25✔
1633
            $this->currentSubdomain = $this->determineCurrentSubdomain();
25✔
1634
        }
1635

1636
        if (! is_array($subdomains)) {
25✔
1637
            $subdomains = [$subdomains];
25✔
1638
        }
1639

1640
        // Routes can be limited to any sub-domain. In that case, though,
1641
        // it does require a sub-domain to be present.
1642
        if (! empty($this->currentSubdomain) && in_array('*', $subdomains, true)) {
25✔
1643
            return true;
9✔
1644
        }
1645

1646
        return in_array($this->currentSubdomain, $subdomains, true);
23✔
1647
    }
1648

1649
    /**
1650
     * Examines the HTTP_HOST to get the best match for the subdomain. It
1651
     * won't be perfect, but should work for our needs.
1652
     *
1653
     * It's especially not perfect since it's possible to register a domain
1654
     * with a period (.) as part of the domain name.
1655
     *
1656
     * @return false|string the subdomain
1657
     */
1658
    private function determineCurrentSubdomain()
1659
    {
1660
        // We have to ensure that a scheme exists
1661
        // on the URL else parse_url will mis-interpret
1662
        // 'host' as the 'path'.
1663
        $url = $this->httpHost;
25✔
1664
        if (! str_starts_with($url, 'http')) {
25✔
1665
            $url = 'http://' . $url;
25✔
1666
        }
1667

1668
        $parsedUrl = parse_url($url);
25✔
1669

1670
        $host = explode('.', $parsedUrl['host']);
25✔
1671

1672
        if ($host[0] === 'www') {
25✔
1673
            unset($host[0]);
3✔
1674
        }
1675

1676
        // Get rid of any domains, which will be the last
1677
        unset($host[count($host) - 1]);
25✔
1678

1679
        // Account for .co.uk, .co.nz, etc. domains
1680
        if (end($host) === 'co') {
25✔
1681
            $host = array_slice($host, 0, -1);
×
1682
        }
1683

1684
        // If we only have 1 part left, then we don't have a sub-domain.
1685
        if (count($host) === 1) {
25✔
1686
            // Set it to false so we don't make it back here again.
1687
            return false;
6✔
1688
        }
1689

1690
        return array_shift($host);
19✔
1691
    }
1692

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

1703
        foreach ($this->defaultHTTPMethods as $verb) {
46✔
1704
            $this->routes[$verb]      = [];
46✔
1705
            $this->routesNames[$verb] = [];
46✔
1706
        }
1707

1708
        $this->routesOptions = [];
46✔
1709

1710
        $this->prioritizeDetected = false;
46✔
1711
        $this->didDiscover        = false;
46✔
1712
    }
1713

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

1730
        $options = $this->routesOptions[$verb] ?? [];
141✔
1731

1732
        if (isset($this->routesOptions['*'])) {
141✔
1733
            foreach ($this->routesOptions['*'] as $key => $val) {
122✔
1734
                if (isset($options[$key])) {
122✔
1735
                    $extraOptions  = array_diff_key($val, $options[$key]);
7✔
1736
                    $options[$key] = array_merge($options[$key], $extraOptions);
7✔
1737
                } else {
1738
                    $options[$key] = $val;
116✔
1739
                }
1740
            }
1741
        }
1742

1743
        return $options;
141✔
1744
    }
1745

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

1757
        return $this;
1✔
1758
    }
1759

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

1780
        /**
1781
         * @deprecated 4.5.0
1782
         * @TODO Remove this in the future.
1783
         */
1784
        $verb = strtoupper($verb);
9✔
1785

1786
        $controllers = [];
9✔
1787

1788
        if ($verb === '*') {
9✔
1789
            foreach ($this->defaultHTTPMethods as $tmpVerb) {
5✔
1790
                foreach ($this->routes[$tmpVerb] as $route) {
5✔
1791
                    $controller = $this->getControllerName($route['handler']);
3✔
1792
                    if ($controller !== null) {
3✔
1793
                        $controllers[] = $controller;
2✔
1794
                    }
1795
                }
1796
            }
1797
        } else {
1798
            $routes = $this->getRoutes($verb);
4✔
1799

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

1808
        return array_unique($controllers);
9✔
1809
    }
1810

1811
    /**
1812
     * @param (Closure(mixed...): (ResponseInterface|string|void))|string $handler Handler
1813
     *
1814
     * @return string|null Controller classname
1815
     */
1816
    private function getControllerName($handler)
1817
    {
1818
        if (! is_string($handler)) {
7✔
1819
            return null;
2✔
1820
        }
1821

1822
        [$controller] = explode('::', $handler, 2);
6✔
1823

1824
        return $controller;
6✔
1825
    }
1826

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

1834
        return $this;
1✔
1835
    }
1836

1837
    /**
1838
     * Get the flag that limit or not the routes with {locale} placeholder to App::$supportedLocales
1839
     */
1840
    public function shouldUseSupportedLocalesOnly(): bool
1841
    {
1842
        return $this->useSupportedLocalesOnly;
2✔
1843
    }
1844
}
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

© 2025 Coveralls, Inc