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

codeigniter4 / CodeIgniter4 / 12673986434

08 Jan 2025 03:42PM UTC coverage: 84.455% (+0.001%) from 84.454%
12673986434

Pull #9385

github

web-flow
Merge 06e47f0ee into e475fd8fa
Pull Request #9385: refactor: Fix phpstan expr.resultUnused

20699 of 24509 relevant lines covered (84.45%)

190.57 hits per line

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

82.9
/system/Router/Router.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\Exceptions\PageNotFoundException;
18
use CodeIgniter\HTTP\Exceptions\BadRequestException;
19
use CodeIgniter\HTTP\Exceptions\RedirectException;
20
use CodeIgniter\HTTP\Method;
21
use CodeIgniter\HTTP\Request;
22
use CodeIgniter\HTTP\ResponseInterface;
23
use CodeIgniter\Router\Exceptions\RouterException;
24
use Config\App;
25
use Config\Feature;
26
use Config\Routing;
27

28
/**
29
 * Request router.
30
 *
31
 * @see \CodeIgniter\Router\RouterTest
32
 */
33
class Router implements RouterInterface
34
{
35
    /**
36
     * List of allowed HTTP methods (and CLI for command line use).
37
     */
38
    public const HTTP_METHODS = [
39
        Method::GET,
40
        Method::HEAD,
41
        Method::POST,
42
        Method::PATCH,
43
        Method::PUT,
44
        Method::DELETE,
45
        Method::OPTIONS,
46
        Method::TRACE,
47
        Method::CONNECT,
48
        'CLI',
49
    ];
50

51
    /**
52
     * A RouteCollection instance.
53
     *
54
     * @var RouteCollectionInterface
55
     */
56
    protected $collection;
57

58
    /**
59
     * Sub-directory that contains the requested controller class.
60
     * Primarily used by 'autoRoute'.
61
     *
62
     * @var string|null
63
     */
64
    protected $directory;
65

66
    /**
67
     * The name of the controller class.
68
     *
69
     * @var (Closure(mixed...): (ResponseInterface|string|void))|string
70
     */
71
    protected $controller;
72

73
    /**
74
     * The name of the method to use.
75
     *
76
     * @var string
77
     */
78
    protected $method;
79

80
    /**
81
     * An array of binds that were collected
82
     * so they can be sent to closure routes.
83
     *
84
     * @var array
85
     */
86
    protected $params = [];
87

88
    /**
89
     * The name of the front controller.
90
     *
91
     * @var string
92
     */
93
    protected $indexPage = 'index.php';
94

95
    /**
96
     * Whether dashes in URI's should be converted
97
     * to underscores when determining method names.
98
     *
99
     * @var bool
100
     */
101
    protected $translateURIDashes = false;
102

103
    /**
104
     * The route that was matched for this request.
105
     *
106
     * @var array|null
107
     */
108
    protected $matchedRoute;
109

110
    /**
111
     * The options set for the matched route.
112
     *
113
     * @var array|null
114
     */
115
    protected $matchedRouteOptions;
116

117
    /**
118
     * The locale that was detected in a route.
119
     *
120
     * @var string
121
     */
122
    protected $detectedLocale;
123

124
    /**
125
     * The filter info from Route Collection
126
     * if the matched route should be filtered.
127
     *
128
     * @var list<string>
129
     */
130
    protected $filtersInfo = [];
131

132
    protected ?AutoRouterInterface $autoRouter = null;
133

134
    /**
135
     * Permitted URI chars
136
     *
137
     * The default value is `''` (do not check) for backward compatibility.
138
     */
139
    protected string $permittedURIChars = '';
140

141
    /**
142
     * Stores a reference to the RouteCollection object.
143
     */
144
    public function __construct(RouteCollectionInterface $routes, ?Request $request = null)
145
    {
146
        $config = config(App::class);
184✔
147

148
        if (isset($config->permittedURIChars)) {
184✔
149
            $this->permittedURIChars = $config->permittedURIChars;
184✔
150
        }
151

152
        $this->collection = $routes;
184✔
153

154
        // These are only for auto-routing
155
        $this->controller = $this->collection->getDefaultController();
184✔
156
        $this->method     = $this->collection->getDefaultMethod();
184✔
157

158
        $this->collection->setHTTPVerb($request->getMethod() === '' ? $_SERVER['REQUEST_METHOD'] : $request->getMethod());
184✔
159

160
        $this->translateURIDashes = $this->collection->shouldTranslateURIDashes();
184✔
161

162
        if ($this->collection->shouldAutoRoute()) {
184✔
163
            $autoRoutesImproved = config(Feature::class)->autoRoutesImproved ?? false;
34✔
164
            if ($autoRoutesImproved) {
34✔
165
                assert($this->collection instanceof RouteCollection);
166

167
                $this->autoRouter = new AutoRouterImproved(
5✔
168
                    $this->collection->getRegisteredControllers('*'),
5✔
169
                    $this->collection->getDefaultNamespace(),
5✔
170
                    $this->collection->getDefaultController(),
5✔
171
                    $this->collection->getDefaultMethod(),
5✔
172
                    $this->translateURIDashes
5✔
173
                );
5✔
174
            } else {
175
                $this->autoRouter = new AutoRouter(
29✔
176
                    $this->collection->getRoutes('CLI', false),
29✔
177
                    $this->collection->getDefaultNamespace(),
29✔
178
                    $this->collection->getDefaultController(),
29✔
179
                    $this->collection->getDefaultMethod(),
29✔
180
                    $this->translateURIDashes
29✔
181
                );
29✔
182
            }
183
        }
184
    }
185

186
    /**
187
     * Finds the controller corresponding to the URI.
188
     *
189
     * @param string|null $uri URI path relative to baseURL
190
     *
191
     * @return (Closure(mixed...): (ResponseInterface|string|void))|string Controller classname or Closure
192
     *
193
     * @throws BadRequestException
194
     * @throws PageNotFoundException
195
     * @throws RedirectException
196
     */
197
    public function handle(?string $uri = null)
198
    {
199
        // If we cannot find a URI to match against, then set it to root (`/`).
200
        if ($uri === null || $uri === '') {
166✔
201
            $uri = '/';
17✔
202
        }
203

204
        // Decode URL-encoded string
205
        $uri = urldecode($uri);
166✔
206

207
        $this->checkDisallowedChars($uri);
166✔
208

209
        // Restart filterInfo
210
        $this->filtersInfo = [];
165✔
211

212
        // Checks defined routes
213
        if ($this->checkRoutes($uri)) {
165✔
214
            if ($this->collection->isFiltered($this->matchedRoute[0])) {
125✔
215
                $this->filtersInfo = $this->collection->getFiltersForRoute($this->matchedRoute[0]);
15✔
216
            }
217

218
            return $this->controller;
125✔
219
        }
220

221
        // Still here? Then we can try to match the URI against
222
        // Controllers/directories, but the application may not
223
        // want this, like in the case of API's.
224
        if (! $this->collection->shouldAutoRoute()) {
26✔
225
            throw new PageNotFoundException(
8✔
226
                "Can't find a route for '{$this->collection->getHTTPVerb()}: {$uri}'."
8✔
227
            );
8✔
228
        }
229

230
        // Checks auto routes
231
        $this->autoRoute($uri);
18✔
232

233
        return $this->controllerName();
12✔
234
    }
235

236
    /**
237
     * Returns the filter info for the matched route, if any.
238
     *
239
     * @return list<string>
240
     */
241
    public function getFilters(): array
242
    {
243
        return $this->filtersInfo;
95✔
244
    }
245

246
    /**
247
     * Returns the name of the matched controller or closure.
248
     *
249
     * @return (Closure(mixed...): (ResponseInterface|string|void))|string Controller classname or Closure
250
     */
251
    public function controllerName()
252
    {
253
        return $this->translateURIDashes && ! $this->controller instanceof Closure
48✔
254
            ? str_replace('-', '_', $this->controller)
×
255
            : $this->controller;
48✔
256
    }
257

258
    /**
259
     * Returns the name of the method to run in the
260
     * chosen controller.
261
     */
262
    public function methodName(): string
263
    {
264
        return $this->translateURIDashes
111✔
265
            ? str_replace('-', '_', $this->method)
×
266
            : $this->method;
111✔
267
    }
268

269
    /**
270
     * Returns the 404 Override settings from the Collection.
271
     * If the override is a string, will split to controller/index array.
272
     */
273
    public function get404Override()
274
    {
275
        $route = $this->collection->get404Override();
11✔
276

277
        if (is_string($route)) {
11✔
278
            $routeArray = explode('::', $route);
3✔
279

280
            return [
3✔
281
                $routeArray[0], // Controller
3✔
282
                $routeArray[1] ?? 'index',   // Method
3✔
283
            ];
3✔
284
        }
285

286
        if (is_callable($route)) {
8✔
287
            return $route;
1✔
288
        }
289

290
        return null;
7✔
291
    }
292

293
    /**
294
     * Returns the binds that have been matched and collected
295
     * during the parsing process as an array, ready to send to
296
     * instance->method(...$params).
297
     */
298
    public function params(): array
299
    {
300
        return $this->params;
84✔
301
    }
302

303
    /**
304
     * Returns the name of the sub-directory the controller is in,
305
     * if any. Relative to APPPATH.'Controllers'.
306
     *
307
     * Only used when auto-routing is turned on.
308
     */
309
    public function directory(): string
310
    {
311
        if ($this->autoRouter instanceof AutoRouter) {
8✔
312
            return $this->autoRouter->directory();
8✔
313
        }
314

315
        return '';
×
316
    }
317

318
    /**
319
     * Returns the routing information that was matched for this
320
     * request, if a route was defined.
321
     *
322
     * @return array|null
323
     */
324
    public function getMatchedRoute()
325
    {
326
        return $this->matchedRoute;
×
327
    }
328

329
    /**
330
     * Returns all options set for the matched route
331
     *
332
     * @return array|null
333
     */
334
    public function getMatchedRouteOptions()
335
    {
336
        return $this->matchedRouteOptions;
1✔
337
    }
338

339
    /**
340
     * Sets the value that should be used to match the index.php file. Defaults
341
     * to index.php but this allows you to modify it in case you are using
342
     * something like mod_rewrite to remove the page. This allows you to set
343
     * it a blank.
344
     *
345
     * @param string $page
346
     */
347
    public function setIndexPage($page): self
348
    {
349
        $this->indexPage = $page;
×
350

351
        return $this;
×
352
    }
353

354
    /**
355
     * Tells the system whether we should translate URI dashes or not
356
     * in the URI from a dash to an underscore.
357
     *
358
     * @deprecated This method should be removed.
359
     */
360
    public function setTranslateURIDashes(bool $val = false): self
361
    {
362
        if ($this->autoRouter instanceof AutoRouter) {
14✔
363
            $this->autoRouter->setTranslateURIDashes($val);
11✔
364

365
            return $this;
11✔
366
        }
367

368
        return $this;
3✔
369
    }
370

371
    /**
372
     * Returns true/false based on whether the current route contained
373
     * a {locale} placeholder.
374
     *
375
     * @return bool
376
     */
377
    public function hasLocale()
378
    {
379
        return (bool) $this->detectedLocale;
73✔
380
    }
381

382
    /**
383
     * Returns the detected locale, if any, or null.
384
     *
385
     * @return string
386
     */
387
    public function getLocale()
388
    {
389
        return $this->detectedLocale;
1✔
390
    }
391

392
    /**
393
     * Checks Defined Routes.
394
     *
395
     * Compares the uri string against the routes that the
396
     * RouteCollection class defined for us, attempting to find a match.
397
     * This method will modify $this->controller, etal as needed.
398
     *
399
     * @param string $uri The URI path to compare against the routes
400
     *
401
     * @return bool Whether the route was matched or not.
402
     *
403
     * @throws RedirectException
404
     */
405
    protected function checkRoutes(string $uri): bool
406
    {
407
        $routes = $this->collection->getRoutes($this->collection->getHTTPVerb());
165✔
408

409
        // Don't waste any time
410
        if ($routes === []) {
165✔
411
            return false;
3✔
412
        }
413

414
        $uri = $uri === '/'
163✔
415
            ? $uri
37✔
416
            : trim($uri, '/ ');
134✔
417

418
        // Loop through the route array looking for wildcards
419
        foreach ($routes as $routeKey => $handler) {
163✔
420
            $routeKey = $routeKey === '/'
163✔
421
                ? $routeKey
87✔
422
                // $routeKey may be int, because it is an array key,
163✔
423
                // and the URI `/1` is valid. The leading `/` is removed.
163✔
424
                : ltrim((string) $routeKey, '/ ');
137✔
425

426
            $matchedKey = $routeKey;
163✔
427

428
            // Are we dealing with a locale?
429
            if (str_contains($routeKey, '{locale}')) {
163✔
430
                $routeKey = str_replace('{locale}', '[^/]+', $routeKey);
18✔
431
            }
432

433
            // Does the RegEx match?
434
            if (preg_match('#^' . $routeKey . '$#u', $uri, $matches)) {
163✔
435
                // Is this route supposed to redirect to another?
436
                if ($this->collection->isRedirect($routeKey)) {
141✔
437
                    // replacing matched route groups with references: post/([0-9]+) -> post/$1
438
                    $redirectTo = preg_replace_callback('/(\([^\(]+\))/', static function (): string {
13✔
439
                        static $i = 1;
2✔
440

441
                        return '$' . $i++;
2✔
442
                    }, is_array($handler) ? key($handler) : $handler);
13✔
443

444
                    throw new RedirectException(
13✔
445
                        preg_replace('#\A' . $routeKey . '\z#u', $redirectTo, $uri),
13✔
446
                        $this->collection->getRedirectCode($routeKey)
13✔
447
                    );
13✔
448
                }
449
                // Store our locale so CodeIgniter object can
450
                // assign it to the Request.
451
                if (str_contains($matchedKey, '{locale}')) {
128✔
452
                    preg_match(
2✔
453
                        '#^' . str_replace('{locale}', '(?<locale>[^/]+)', $matchedKey) . '$#u',
2✔
454
                        $uri,
2✔
455
                        $matched
2✔
456
                    );
2✔
457

458
                    if ($this->collection->shouldUseSupportedLocalesOnly()
2✔
459
                        && ! in_array($matched['locale'], config(App::class)->supportedLocales, true)) {
2✔
460
                        // Throw exception to prevent the autorouter, if enabled,
461
                        // from trying to find a route
462
                        throw PageNotFoundException::forLocaleNotSupported($matched['locale']);
1✔
463
                    }
464

465
                    $this->detectedLocale = $matched['locale'];
1✔
466
                    unset($matched);
1✔
467
                }
468

469
                // Are we using Closures? If so, then we need
470
                // to collect the params into an array
471
                // so it can be passed to the controller method later.
472
                if (! is_string($handler) && is_callable($handler)) {
127✔
473
                    $this->controller = $handler;
41✔
474

475
                    // Remove the original string from the matches array
476
                    array_shift($matches);
41✔
477

478
                    $this->params = $matches;
41✔
479

480
                    $this->setMatchedRoute($matchedKey, $handler);
41✔
481

482
                    return true;
41✔
483
                }
484

485
                if (str_contains($handler, '::')) {
93✔
486
                    [$controller, $methodAndParams] = explode('::', $handler);
91✔
487
                } else {
488
                    $controller      = $handler;
2✔
489
                    $methodAndParams = '';
2✔
490
                }
491

492
                // Checks `/` in controller name
493
                if (str_contains($controller, '/')) {
93✔
494
                    throw RouterException::forInvalidControllerName($handler);
1✔
495
                }
496

497
                if (str_contains($handler, '$') && str_contains($routeKey, '(')) {
92✔
498
                    // Checks dynamic controller
499
                    if (str_contains($controller, '$')) {
23✔
500
                        throw RouterException::forDynamicController($handler);
1✔
501
                    }
502

503
                    if (config(Routing::class)->multipleSegmentsOneParam === false) {
22✔
504
                        // Using back-references
505
                        $segments = explode('/', preg_replace('#\A' . $routeKey . '\z#u', $handler, $uri));
21✔
506
                    } else {
507
                        if (str_contains($methodAndParams, '/')) {
1✔
508
                            [$method, $handlerParams] = explode('/', $methodAndParams, 2);
1✔
509
                            $params                   = explode('/', $handlerParams);
1✔
510
                            $handlerSegments          = array_merge([$controller . '::' . $method], $params);
1✔
511
                        } else {
512
                            $handlerSegments = [$handler];
×
513
                        }
514

515
                        $segments = [];
1✔
516

517
                        foreach ($handlerSegments as $segment) {
1✔
518
                            $segments[] = $this->replaceBackReferences($segment, $matches);
1✔
519
                        }
520
                    }
521
                } else {
522
                    $segments = explode('/', $handler);
71✔
523
                }
524

525
                $this->setRequest($segments);
91✔
526

527
                $this->setMatchedRoute($matchedKey, $handler);
91✔
528

529
                return true;
91✔
530
            }
531
        }
532

533
        return false;
23✔
534
    }
535

536
    /**
537
     * Replace string `$n` with `$matches[n]` value.
538
     */
539
    private function replaceBackReferences(string $input, array $matches): string
540
    {
541
        $pattern = '/\$([1-' . count($matches) . '])/u';
1✔
542

543
        return preg_replace_callback(
1✔
544
            $pattern,
1✔
545
            static function ($match) use ($matches) {
1✔
546
                $index = (int) $match[1];
1✔
547

548
                return $matches[$index] ?? '';
1✔
549
            },
1✔
550
            $input
1✔
551
        );
1✔
552
    }
553

554
    /**
555
     * Checks Auto Routes.
556
     *
557
     * Attempts to match a URI path against Controllers and directories
558
     * found in APPPATH/Controllers, to find a matching route.
559
     *
560
     * @return void
561
     */
562
    public function autoRoute(string $uri)
563
    {
564
        [$this->directory, $this->controller, $this->method, $this->params]
34✔
565
            = $this->autoRouter->getRoute($uri, $this->collection->getHTTPVerb());
34✔
566
    }
567

568
    /**
569
     * Scans the controller directory, attempting to locate a controller matching the supplied uri $segments
570
     *
571
     * @param array $segments URI segments
572
     *
573
     * @return array returns an array of remaining uri segments that don't map onto a directory
574
     *
575
     * @deprecated this function name does not properly describe its behavior so it has been deprecated
576
     *
577
     * @codeCoverageIgnore
578
     */
579
    protected function validateRequest(array $segments): array
580
    {
581
        return $this->scanControllers($segments);
×
582
    }
583

584
    /**
585
     * Scans the controller directory, attempting to locate a controller matching the supplied uri $segments
586
     *
587
     * @param array $segments URI segments
588
     *
589
     * @return array returns an array of remaining uri segments that don't map onto a directory
590
     *
591
     * @deprecated Not used. Moved to AutoRouter class.
592
     */
593
    protected function scanControllers(array $segments): array
594
    {
595
        $segments = array_filter($segments, static fn ($segment): bool => $segment !== '');
×
596
        // numerically reindex the array, removing gaps
597
        $segments = array_values($segments);
×
598

599
        // if a prior directory value has been set, just return segments and get out of here
600
        if (isset($this->directory)) {
×
601
            return $segments;
×
602
        }
603

604
        // Loop through our segments and return as soon as a controller
605
        // is found or when such a directory doesn't exist
606
        $c = count($segments);
×
607

608
        while ($c-- > 0) {
×
609
            $segmentConvert = ucfirst($this->translateURIDashes === true ? str_replace('-', '_', $segments[0]) : $segments[0]);
×
610
            // as soon as we encounter any segment that is not PSR-4 compliant, stop searching
611
            if (! $this->isValidSegment($segmentConvert)) {
×
612
                return $segments;
×
613
            }
614

615
            $test = APPPATH . 'Controllers/' . $this->directory . $segmentConvert;
×
616

617
            // as long as each segment is *not* a controller file but does match a directory, add it to $this->directory
618
            if (! is_file($test . '.php') && is_dir($test)) {
×
619
                $this->setDirectory($segmentConvert, true, false);
×
620
                array_shift($segments);
×
621

622
                continue;
×
623
            }
624

625
            return $segments;
×
626
        }
627

628
        // This means that all segments were actually directories
629
        return $segments;
×
630
    }
631

632
    /**
633
     * Sets the sub-directory that the controller is in.
634
     *
635
     * @param bool $validate if true, checks to make sure $dir consists of only PSR4 compliant segments
636
     *
637
     * @return void
638
     *
639
     * @deprecated This method should be removed.
640
     */
641
    public function setDirectory(?string $dir = null, bool $append = false, bool $validate = true)
642
    {
643
        if ($dir === null || $dir === '') {
35✔
644
            $this->directory = null;
32✔
645
        }
646

647
        if ($this->autoRouter instanceof AutoRouter) {
35✔
648
            $this->autoRouter->setDirectory($dir, $append, $validate);
4✔
649
        }
650
    }
651

652
    /**
653
     * Returns true if the supplied $segment string represents a valid PSR-4 compliant namespace/directory segment
654
     *
655
     * regex comes from https://www.php.net/manual/en/language.variables.basics.php
656
     *
657
     * @deprecated Moved to AutoRouter class.
658
     */
659
    private function isValidSegment(string $segment): bool
660
    {
661
        return (bool) preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $segment);
×
662
    }
663

664
    /**
665
     * Set request route
666
     *
667
     * Takes an array of URI segments as input and sets the class/method
668
     * to be called.
669
     *
670
     * @param array $segments URI segments
671
     *
672
     * @return void
673
     */
674
    protected function setRequest(array $segments = [])
675
    {
676
        // If we don't have any segments - use the default controller;
677
        if ($segments === []) {
91✔
678
            return;
×
679
        }
680

681
        [$controller, $method] = array_pad(explode('::', $segments[0]), 2, null);
91✔
682

683
        $this->controller = $controller;
91✔
684

685
        // $this->method already contains the default method name,
686
        // so don't overwrite it with emptiness.
687
        if (! empty($method)) {
91✔
688
            $this->method = $method;
89✔
689
        }
690

691
        array_shift($segments);
91✔
692

693
        $this->params = $segments;
91✔
694
    }
695

696
    /**
697
     * Sets the default controller based on the info set in the RouteCollection.
698
     *
699
     * @deprecated This was an unnecessary method, so it is no longer used.
700
     *
701
     * @return void
702
     */
703
    protected function setDefaultController()
704
    {
705
        if (empty($this->controller)) {
×
706
            throw RouterException::forMissingDefaultRoute();
×
707
        }
708

709
        sscanf($this->controller, '%[^/]/%s', $class, $this->method);
×
710

711
        if (! is_file(APPPATH . 'Controllers/' . $this->directory . ucfirst($class) . '.php')) {
×
712
            return;
×
713
        }
714

715
        $this->controller = ucfirst($class);
×
716

717
        log_message('info', 'Used the default controller.');
×
718
    }
719

720
    /**
721
     * @param callable|string $handler
722
     */
723
    protected function setMatchedRoute(string $route, $handler): void
724
    {
725
        $this->matchedRoute = [$route, $handler];
125✔
726

727
        $this->matchedRouteOptions = $this->collection->getRoutesOptions($route);
125✔
728
    }
729

730
    /**
731
     * Checks disallowed characters
732
     */
733
    private function checkDisallowedChars(string $uri): void
734
    {
735
        foreach (explode('/', $uri) as $segment) {
166✔
736
            if ($segment !== '' && $this->permittedURIChars !== ''
166✔
737
                && preg_match('/\A[' . $this->permittedURIChars . ']+\z/iu', $segment) !== 1
166✔
738
            ) {
739
                throw new BadRequestException(
2✔
740
                    'The URI you submitted has disallowed characters: "' . $segment . '"'
2✔
741
                );
2✔
742
            }
743
        }
744
    }
745
}
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