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

codeigniter4 / CodeIgniter4 / 18293416558

06 Oct 2025 08:22PM UTC coverage: 84.359% (+0.03%) from 84.325%
18293416558

Pull #9745

github

web-flow
Merge 663daeb42 into 3473349b6
Pull Request #9745: feat(app): Added controller attributes

142 of 158 new or added lines in 5 files covered. (89.87%)

55 existing lines in 1 file now uncovered.

21234 of 25171 relevant lines covered (84.36%)

195.22 hits per line

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

81.67
/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\RequestInterface;
23
use CodeIgniter\HTTP\ResponseInterface;
24
use CodeIgniter\Router\Attributes\Filter;
25
use CodeIgniter\Router\Attributes\RouteAttributeInterface;
26
use CodeIgniter\Router\Exceptions\RouterException;
27
use Config\App;
28
use Config\Feature;
29
use Config\Routing;
30
use ReflectionClass;
31
use Throwable;
32

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

56
    /**
57
     * A RouteCollection instance.
58
     *
59
     * @var RouteCollectionInterface
60
     */
61
    protected $collection;
62

63
    /**
64
     * Sub-directory that contains the requested controller class.
65
     * Primarily used by 'autoRoute'.
66
     *
67
     * @var string|null
68
     */
69
    protected $directory;
70

71
    /**
72
     * The name of the controller class.
73
     *
74
     * @var (Closure(mixed...): (ResponseInterface|string|void))|string
75
     */
76
    protected $controller;
77

78
    /**
79
     * The name of the method to use.
80
     *
81
     * @var string
82
     */
83
    protected $method;
84

85
    /**
86
     * An array of binds that were collected
87
     * so they can be sent to closure routes.
88
     *
89
     * @var array
90
     */
91
    protected $params = [];
92

93
    /**
94
     * The name of the front controller.
95
     *
96
     * @var string
97
     */
98
    protected $indexPage = 'index.php';
99

100
    /**
101
     * Whether dashes in URI's should be converted
102
     * to underscores when determining method names.
103
     *
104
     * @var bool
105
     */
106
    protected $translateURIDashes = false;
107

108
    /**
109
     * The route that was matched for this request.
110
     *
111
     * @var array|null
112
     */
113
    protected $matchedRoute;
114

115
    /**
116
     * The options set for the matched route.
117
     *
118
     * @var array|null
119
     */
120
    protected $matchedRouteOptions;
121

122
    /**
123
     * The locale that was detected in a route.
124
     *
125
     * @var string
126
     */
127
    protected $detectedLocale;
128

129
    /**
130
     * The filter info from Route Collection
131
     * if the matched route should be filtered.
132
     *
133
     * @var list<string>
134
     */
135
    protected $filtersInfo = [];
136

137
    protected ?AutoRouterInterface $autoRouter = null;
138

139
    /**
140
     * Route attributes collected during routing for the current route.
141
     *
142
     * @var array{class: list<RouteAttributeInterface>, method: list<RouteAttributeInterface>}
143
     */
144
    protected array $routeAttributes = ['class' => [], 'method' => []];
145

146
    /**
147
     * Permitted URI chars
148
     *
149
     * The default value is `''` (do not check) for backward compatibility.
150
     */
151
    protected string $permittedURIChars = '';
152

153
    /**
154
     * Stores a reference to the RouteCollection object.
155
     */
156
    public function __construct(RouteCollectionInterface $routes, ?Request $request = null)
157
    {
158
        $config = config(App::class);
191✔
159

160
        if (isset($config->permittedURIChars)) {
191✔
161
            $this->permittedURIChars = $config->permittedURIChars;
191✔
162
        }
163

164
        $this->collection = $routes;
191✔
165

166
        // These are only for auto-routing
167
        $this->controller = $this->collection->getDefaultController();
191✔
168
        $this->method     = $this->collection->getDefaultMethod();
191✔
169

170
        $this->collection->setHTTPVerb($request->getMethod() === '' ? $_SERVER['REQUEST_METHOD'] : $request->getMethod());
191✔
171

172
        $this->translateURIDashes = $this->collection->shouldTranslateURIDashes();
191✔
173

174
        if ($this->collection->shouldAutoRoute()) {
191✔
175
            $autoRoutesImproved = config(Feature::class)->autoRoutesImproved ?? false;
34✔
176
            if ($autoRoutesImproved) {
34✔
177
                assert($this->collection instanceof RouteCollection);
178

179
                $this->autoRouter = new AutoRouterImproved(
5✔
180
                    $this->collection->getRegisteredControllers('*'),
5✔
181
                    $this->collection->getDefaultNamespace(),
5✔
182
                    $this->collection->getDefaultController(),
5✔
183
                    $this->collection->getDefaultMethod(),
5✔
184
                    $this->translateURIDashes,
5✔
185
                );
5✔
186
            } else {
187
                $this->autoRouter = new AutoRouter(
29✔
188
                    $this->collection->getRoutes('CLI', false),
29✔
189
                    $this->collection->getDefaultNamespace(),
29✔
190
                    $this->collection->getDefaultController(),
29✔
191
                    $this->collection->getDefaultMethod(),
29✔
192
                    $this->translateURIDashes,
29✔
193
                );
29✔
194
            }
195
        }
196
    }
197

198
    /**
199
     * Finds the controller corresponding to the URI.
200
     *
201
     * @param string|null $uri URI path relative to baseURL
202
     *
203
     * @return (Closure(mixed...): (ResponseInterface|string|void))|string Controller classname or Closure
204
     *
205
     * @throws BadRequestException
206
     * @throws PageNotFoundException
207
     * @throws RedirectException
208
     */
209
    public function handle(?string $uri = null)
210
    {
211
        // If we cannot find a URI to match against, then set it to root (`/`).
212
        if ($uri === null || $uri === '') {
172✔
213
            $uri = '/';
16✔
214
        }
215

216
        // Decode URL-encoded string
217
        $uri = urldecode($uri);
172✔
218

219
        $this->checkDisallowedChars($uri);
172✔
220

221
        // Restart filterInfo
222
        $this->filtersInfo = [];
171✔
223

224
        // Checks defined routes
225
        if ($this->checkRoutes($uri)) {
171✔
226
            if ($this->collection->isFiltered($this->matchedRoute[0])) {
131✔
227
                $this->filtersInfo = $this->collection->getFiltersForRoute($this->matchedRoute[0]);
15✔
228
            }
229

230
            $this->processRouteAttributes();
131✔
231

232
            return $this->controller;
131✔
233
        }
234

235
        // Still here? Then we can try to match the URI against
236
        // Controllers/directories, but the application may not
237
        // want this, like in the case of API's.
238
        if (! $this->collection->shouldAutoRoute()) {
26✔
239
            throw new PageNotFoundException(
8✔
240
                "Can't find a route for '{$this->collection->getHTTPVerb()}: {$uri}'.",
8✔
241
            );
8✔
242
        }
243

244
        // Checks auto routes
245
        $this->autoRoute($uri);
18✔
246

247
        $this->processRouteAttributes();
12✔
248

249
        return $this->controllerName();
12✔
250
    }
251

252
    /**
253
     * Returns the filter info for the matched route, if any.
254
     *
255
     * @return list<string>
256
     */
257
    public function getFilters(): array
258
    {
259
        $filters = $this->filtersInfo;
101✔
260

261
        // Check for attribute-based filters
262
        foreach ($this->routeAttributes as $attributes) {
101✔
263
            foreach ($attributes as $attribute) {
101✔
264
                if ($attribute instanceof Filter) {
6✔
265
                    $filters = array_merge($filters, $attribute->getFilters());
2✔
266
                }
267
            }
268
        }
269

270
        return $filters;
101✔
271
    }
272

273
    /**
274
     * Returns the name of the matched controller or closure.
275
     *
276
     * @return (Closure(mixed...): (ResponseInterface|string|void))|string Controller classname or Closure
277
     */
278
    public function controllerName()
279
    {
280
        return $this->translateURIDashes && ! $this->controller instanceof Closure
48✔
281
            ? str_replace('-', '_', $this->controller)
×
282
            : $this->controller;
48✔
283
    }
284

285
    /**
286
     * Returns the name of the method to run in the
287
     * chosen controller.
288
     */
289
    public function methodName(): string
290
    {
291
        return $this->translateURIDashes
117✔
292
            ? str_replace('-', '_', $this->method)
×
293
            : $this->method;
117✔
294
    }
295

296
    /**
297
     * Returns the 404 Override settings from the Collection.
298
     * If the override is a string, will split to controller/index array.
299
     *
300
     * @return array{string, string}|(Closure(string): (ResponseInterface|string|void))|null
301
     */
302
    public function get404Override()
303
    {
304
        $route = $this->collection->get404Override();
12✔
305

306
        if (is_string($route)) {
12✔
307
            $routeArray = explode('::', $route);
3✔
308

309
            return [
3✔
310
                $routeArray[0], // Controller
3✔
311
                $routeArray[1] ?? 'index',   // Method
3✔
312
            ];
3✔
313
        }
314

315
        if (is_callable($route)) {
9✔
316
            return $route;
1✔
317
        }
318

319
        return null;
8✔
320
    }
321

322
    /**
323
     * Returns the binds that have been matched and collected
324
     * during the parsing process as an array, ready to send to
325
     * instance->method(...$params).
326
     */
327
    public function params(): array
328
    {
329
        return $this->params;
89✔
330
    }
331

332
    /**
333
     * Returns the name of the sub-directory the controller is in,
334
     * if any. Relative to APPPATH.'Controllers'.
335
     *
336
     * Only used when auto-routing is turned on.
337
     */
338
    public function directory(): string
339
    {
340
        if ($this->autoRouter instanceof AutoRouter) {
8✔
341
            return $this->autoRouter->directory();
8✔
342
        }
343

344
        return '';
×
345
    }
346

347
    /**
348
     * Returns the routing information that was matched for this
349
     * request, if a route was defined.
350
     *
351
     * @return array|null
352
     */
353
    public function getMatchedRoute()
354
    {
355
        return $this->matchedRoute;
×
356
    }
357

358
    /**
359
     * Returns all options set for the matched route
360
     *
361
     * @return array|null
362
     */
363
    public function getMatchedRouteOptions()
364
    {
365
        return $this->matchedRouteOptions;
1✔
366
    }
367

368
    /**
369
     * Sets the value that should be used to match the index.php file. Defaults
370
     * to index.php but this allows you to modify it in case you are using
371
     * something like mod_rewrite to remove the page. This allows you to set
372
     * it a blank.
373
     *
374
     * @param string $page
375
     */
376
    public function setIndexPage($page): self
377
    {
378
        $this->indexPage = $page;
×
379

380
        return $this;
×
381
    }
382

383
    /**
384
     * Tells the system whether we should translate URI dashes or not
385
     * in the URI from a dash to an underscore.
386
     *
387
     * @deprecated This method should be removed.
388
     */
389
    public function setTranslateURIDashes(bool $val = false): self
390
    {
391
        if ($this->autoRouter instanceof AutoRouter) {
14✔
392
            $this->autoRouter->setTranslateURIDashes($val);
11✔
393

394
            return $this;
11✔
395
        }
396

397
        return $this;
3✔
398
    }
399

400
    /**
401
     * Returns true/false based on whether the current route contained
402
     * a {locale} placeholder.
403
     *
404
     * @return bool
405
     */
406
    public function hasLocale()
407
    {
408
        return (bool) $this->detectedLocale;
79✔
409
    }
410

411
    /**
412
     * Returns the detected locale, if any, or null.
413
     *
414
     * @return string
415
     */
416
    public function getLocale()
417
    {
418
        return $this->detectedLocale;
1✔
419
    }
420

421
    /**
422
     * Checks Defined Routes.
423
     *
424
     * Compares the uri string against the routes that the
425
     * RouteCollection class defined for us, attempting to find a match.
426
     * This method will modify $this->controller, etal as needed.
427
     *
428
     * @param string $uri The URI path to compare against the routes
429
     *
430
     * @return bool Whether the route was matched or not.
431
     *
432
     * @throws RedirectException
433
     */
434
    protected function checkRoutes(string $uri): bool
435
    {
436
        $routes = $this->collection->getRoutes($this->collection->getHTTPVerb());
171✔
437

438
        // Don't waste any time
439
        if ($routes === []) {
171✔
440
            return false;
3✔
441
        }
442

443
        $uri = $uri === '/'
169✔
444
            ? $uri
36✔
445
            : trim($uri, '/ ');
141✔
446

447
        // Loop through the route array looking for wildcards
448
        foreach ($routes as $routeKey => $handler) {
169✔
449
            $routeKey = $routeKey === '/'
169✔
450
                ? $routeKey
86✔
451
                // $routeKey may be int, because it is an array key,
169✔
452
                // and the URI `/1` is valid. The leading `/` is removed.
169✔
453
                : ltrim((string) $routeKey, '/ ');
144✔
454

455
            $matchedKey = $routeKey;
169✔
456

457
            // Are we dealing with a locale?
458
            if (str_contains($routeKey, '{locale}')) {
169✔
459
                $routeKey = str_replace('{locale}', '[^/]+', $routeKey);
18✔
460
            }
461

462
            // Does the RegEx match?
463
            if (preg_match('#^' . $routeKey . '$#u', $uri, $matches)) {
169✔
464
                // Is this route supposed to redirect to another?
465
                if ($this->collection->isRedirect($routeKey)) {
147✔
466
                    // replacing matched route groups with references: post/([0-9]+) -> post/$1
467
                    $redirectTo = preg_replace_callback('/(\([^\(]+\))/', static function (): string {
13✔
468
                        static $i = 1;
2✔
469

470
                        return '$' . $i++;
2✔
471
                    }, is_array($handler) ? key($handler) : $handler);
13✔
472

473
                    throw new RedirectException(
13✔
474
                        preg_replace('#\A' . $routeKey . '\z#u', $redirectTo, $uri),
13✔
475
                        $this->collection->getRedirectCode($routeKey),
13✔
476
                    );
13✔
477
                }
478
                // Store our locale so CodeIgniter object can
479
                // assign it to the Request.
480
                if (str_contains($matchedKey, '{locale}')) {
134✔
481
                    preg_match(
2✔
482
                        '#^' . str_replace('{locale}', '(?<locale>[^/]+)', $matchedKey) . '$#u',
2✔
483
                        $uri,
2✔
484
                        $matched,
2✔
485
                    );
2✔
486

487
                    if ($this->collection->shouldUseSupportedLocalesOnly()
2✔
488
                        && ! in_array($matched['locale'], config(App::class)->supportedLocales, true)) {
2✔
489
                        // Throw exception to prevent the autorouter, if enabled,
490
                        // from trying to find a route
491
                        throw PageNotFoundException::forLocaleNotSupported($matched['locale']);
1✔
492
                    }
493

494
                    $this->detectedLocale = $matched['locale'];
1✔
495
                    unset($matched);
1✔
496
                }
497

498
                // Are we using Closures? If so, then we need
499
                // to collect the params into an array
500
                // so it can be passed to the controller method later.
501
                if (! is_string($handler) && is_callable($handler)) {
133✔
502
                    $this->controller = $handler;
40✔
503

504
                    // Remove the original string from the matches array
505
                    array_shift($matches);
40✔
506

507
                    $this->params = $matches;
40✔
508

509
                    $this->setMatchedRoute($matchedKey, $handler);
40✔
510

511
                    return true;
40✔
512
                }
513

514
                if (str_contains($handler, '::')) {
100✔
515
                    [$controller, $methodAndParams] = explode('::', $handler);
98✔
516
                } else {
517
                    $controller      = $handler;
2✔
518
                    $methodAndParams = '';
2✔
519
                }
520

521
                // Checks `/` in controller name
522
                if (str_contains($controller, '/')) {
100✔
523
                    throw RouterException::forInvalidControllerName($handler);
1✔
524
                }
525

526
                if (str_contains($handler, '$') && str_contains($routeKey, '(')) {
99✔
527
                    // Checks dynamic controller
528
                    if (str_contains($controller, '$')) {
23✔
529
                        throw RouterException::forDynamicController($handler);
1✔
530
                    }
531

532
                    if (config(Routing::class)->multipleSegmentsOneParam === false) {
22✔
533
                        // Using back-references
534
                        $segments = explode('/', preg_replace('#\A' . $routeKey . '\z#u', $handler, $uri));
21✔
535
                    } else {
536
                        if (str_contains($methodAndParams, '/')) {
1✔
537
                            [$method, $handlerParams] = explode('/', $methodAndParams, 2);
1✔
538
                            $params                   = explode('/', $handlerParams);
1✔
539
                            $handlerSegments          = array_merge([$controller . '::' . $method], $params);
1✔
540
                        } else {
541
                            $handlerSegments = [$handler];
×
542
                        }
543

544
                        $segments = [];
1✔
545

546
                        foreach ($handlerSegments as $segment) {
1✔
547
                            $segments[] = $this->replaceBackReferences($segment, $matches);
1✔
548
                        }
549
                    }
550
                } else {
551
                    $segments = explode('/', $handler);
78✔
552
                }
553

554
                $this->setRequest($segments);
98✔
555

556
                $this->setMatchedRoute($matchedKey, $handler);
98✔
557

558
                return true;
98✔
559
            }
560
        }
561

562
        return false;
23✔
563
    }
564

565
    /**
566
     * Replace string `$n` with `$matches[n]` value.
567
     */
568
    private function replaceBackReferences(string $input, array $matches): string
569
    {
570
        $pattern = '/\$([1-' . count($matches) . '])/u';
1✔
571

572
        return preg_replace_callback(
1✔
573
            $pattern,
1✔
574
            static function ($match) use ($matches) {
1✔
575
                $index = (int) $match[1];
1✔
576

577
                return $matches[$index] ?? '';
1✔
578
            },
1✔
579
            $input,
1✔
580
        );
1✔
581
    }
582

583
    /**
584
     * Checks Auto Routes.
585
     *
586
     * Attempts to match a URI path against Controllers and directories
587
     * found in APPPATH/Controllers, to find a matching route.
588
     *
589
     * @return void
590
     */
591
    public function autoRoute(string $uri)
592
    {
593
        [$this->directory, $this->controller, $this->method, $this->params]
34✔
594
            = $this->autoRouter->getRoute($uri, $this->collection->getHTTPVerb());
34✔
595
    }
596

597
    /**
598
     * Scans the controller directory, attempting to locate a controller matching the supplied uri $segments
599
     *
600
     * @param array $segments URI segments
601
     *
602
     * @return array returns an array of remaining uri segments that don't map onto a directory
603
     *
604
     * @deprecated this function name does not properly describe its behavior so it has been deprecated
605
     *
606
     * @codeCoverageIgnore
607
     */
608
    protected function validateRequest(array $segments): array
609
    {
610
        return $this->scanControllers($segments);
×
611
    }
612

613
    /**
614
     * Scans the controller directory, attempting to locate a controller matching the supplied uri $segments
615
     *
616
     * @param array $segments URI segments
617
     *
618
     * @return array returns an array of remaining uri segments that don't map onto a directory
619
     *
620
     * @deprecated Not used. Moved to AutoRouter class.
621
     */
622
    protected function scanControllers(array $segments): array
623
    {
624
        $segments = array_filter($segments, static fn ($segment): bool => $segment !== '');
×
625
        // numerically reindex the array, removing gaps
626
        $segments = array_values($segments);
×
627

628
        // if a prior directory value has been set, just return segments and get out of here
629
        if (isset($this->directory)) {
×
630
            return $segments;
×
631
        }
632

633
        // Loop through our segments and return as soon as a controller
634
        // is found or when such a directory doesn't exist
635
        $c = count($segments);
×
636

637
        while ($c-- > 0) {
×
638
            $segmentConvert = ucfirst($this->translateURIDashes === true ? str_replace('-', '_', $segments[0]) : $segments[0]);
×
639
            // as soon as we encounter any segment that is not PSR-4 compliant, stop searching
640
            if (! $this->isValidSegment($segmentConvert)) {
×
641
                return $segments;
×
642
            }
643

644
            $test = APPPATH . 'Controllers/' . $this->directory . $segmentConvert;
×
645

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

651
                continue;
×
652
            }
653

654
            return $segments;
×
655
        }
656

657
        // This means that all segments were actually directories
658
        return $segments;
×
659
    }
660

661
    /**
662
     * Sets the sub-directory that the controller is in.
663
     *
664
     * @param bool $validate if true, checks to make sure $dir consists of only PSR4 compliant segments
665
     *
666
     * @return void
667
     *
668
     * @deprecated This method should be removed.
669
     */
670
    public function setDirectory(?string $dir = null, bool $append = false, bool $validate = true)
671
    {
672
        if ($dir === null || $dir === '') {
35✔
673
            $this->directory = null;
32✔
674
        }
675

676
        if ($this->autoRouter instanceof AutoRouter) {
35✔
677
            $this->autoRouter->setDirectory($dir, $append, $validate);
4✔
678
        }
679
    }
680

681
    /**
682
     * Returns true if the supplied $segment string represents a valid PSR-4 compliant namespace/directory segment
683
     *
684
     * regex comes from https://www.php.net/manual/en/language.variables.basics.php
685
     *
686
     * @deprecated Moved to AutoRouter class.
687
     */
688
    private function isValidSegment(string $segment): bool
689
    {
690
        return (bool) preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $segment);
×
691
    }
692

693
    /**
694
     * Set request route
695
     *
696
     * Takes an array of URI segments as input and sets the class/method
697
     * to be called.
698
     *
699
     * @param array $segments URI segments
700
     *
701
     * @return void
702
     */
703
    protected function setRequest(array $segments = [])
704
    {
705
        // If we don't have any segments - use the default controller;
706
        if ($segments === []) {
98✔
707
            return;
×
708
        }
709

710
        [$controller, $method] = array_pad(explode('::', $segments[0]), 2, null);
98✔
711

712
        $this->controller = $controller;
98✔
713

714
        // $this->method already contains the default method name,
715
        // so don't overwrite it with emptiness.
716
        if (! empty($method)) {
98✔
717
            $this->method = $method;
96✔
718
        }
719

720
        array_shift($segments);
98✔
721

722
        $this->params = $segments;
98✔
723
    }
724

725
    /**
726
     * Sets the default controller based on the info set in the RouteCollection.
727
     *
728
     * @deprecated This was an unnecessary method, so it is no longer used.
729
     *
730
     * @return void
731
     */
732
    protected function setDefaultController()
733
    {
734
        if (empty($this->controller)) {
×
735
            throw RouterException::forMissingDefaultRoute();
×
736
        }
737

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

740
        if (! is_file(APPPATH . 'Controllers/' . $this->directory . ucfirst($class) . '.php')) {
×
741
            return;
×
742
        }
743

744
        $this->controller = ucfirst($class);
×
745

746
        log_message('info', 'Used the default controller.');
×
747
    }
748

749
    /**
750
     * @param callable|string $handler
751
     */
752
    protected function setMatchedRoute(string $route, $handler): void
753
    {
754
        $this->matchedRoute = [$route, $handler];
131✔
755

756
        $this->matchedRouteOptions = $this->collection->getRoutesOptions($route);
131✔
757
    }
758

759
    /**
760
     * Checks disallowed characters
761
     */
762
    private function checkDisallowedChars(string $uri): void
763
    {
764
        foreach (explode('/', $uri) as $segment) {
172✔
765
            if ($segment !== '' && $this->permittedURIChars !== ''
172✔
766
                && preg_match('/\A[' . $this->permittedURIChars . ']+\z/iu', $segment) !== 1
172✔
767
            ) {
768
                throw new BadRequestException(
2✔
769
                    'The URI you submitted has disallowed characters: "' . $segment . '"',
2✔
770
                );
2✔
771
            }
772
        }
773
    }
774

775
    /**
776
     * Extracts PHP attributes from the resolved controller and method.
777
     */
778
    private function processRouteAttributes(): void
779
    {
780
        $this->routeAttributes = ['class' => [], 'method' => []];
141✔
781

782
        // Skip if controller is a Closure
783
        if ($this->controller instanceof Closure) {
141✔
784
            return;
40✔
785
        }
786

787
        if (! class_exists($this->controller)) {
108✔
788
            return;
51✔
789
        }
790

791
        $reflectionClass = new ReflectionClass($this->controller);
62✔
792

793
        // Process class-level attributes
794
        foreach ($reflectionClass->getAttributes() as $attribute) {
62✔
795
            try {
NEW
796
                $instance = $attribute->newInstance();
×
797

NEW
798
                if ($instance instanceof RouteAttributeInterface) {
×
NEW
799
                    $this->routeAttributes['class'][] = $instance;
×
800
                }
NEW
801
            } catch (Throwable) {
×
NEW
802
                log_message('error', 'Failed to instantiate attribute: ' . $attribute->getName());
×
803
            }
804
        }
805

806
        if ($this->method === '' || $this->method === null) {
62✔
NEW
807
            return;
×
808
        }
809

810
        // Process method-level attributes
811
        if ($reflectionClass->hasMethod($this->method)) {
62✔
812
            $reflectionMethod = $reflectionClass->getMethod($this->method);
62✔
813

814
            foreach ($reflectionMethod->getAttributes() as $attribute) {
62✔
815
                try {
816
                    $instance = $attribute->newInstance();
6✔
817

818
                    if ($instance instanceof RouteAttributeInterface) {
6✔
819
                        $this->routeAttributes['method'][] = $instance;
6✔
820
                    }
NEW
821
                } catch (Throwable) {
×
822
                    // Skip attributes that fail to instantiate
NEW
823
                    log_message('error', 'Failed to instantiate attribute: ' . $attribute->getName());
×
824
                }
825
            }
826
        }
827
    }
828

829
    /**
830
     * Execute beforeController() on all route attributes.
831
     * Called by CodeIgniter before controller execution.
832
     */
833
    public function executeBeforeAttributes(RequestInterface $request): RequestInterface|ResponseInterface|null
834
    {
835
        // Process class-level attributes first, then method-level
836
        foreach (['class', 'method'] as $level) {
49✔
837
            foreach ($this->routeAttributes[$level] as $attribute) {
49✔
838
                if (! $attribute instanceof RouteAttributeInterface) {
6✔
NEW
839
                    continue;
×
840
                }
841

842
                $result = $attribute->before($request);
6✔
843

844
                // If attribute returns a Response, short-circuit
845
                if ($result instanceof ResponseInterface) {
5✔
846
                    return $result;
1✔
847
                }
848

849
                // If attribute returns a Request, use it
850
                if ($result instanceof RequestInterface) {
5✔
NEW
851
                    $request = $result;
×
852
                }
853
            }
854
        }
855

856
        return $request;
48✔
857
    }
858

859
    /**
860
     * Execute afterController() on all route attributes.
861
     * Called by CodeIgniter after controller execution.
862
     */
863
    public function executeAfterAttributes(RequestInterface $request, ResponseInterface $response): ResponseInterface
864
    {
865
        // Process in reverse order: method-level first, then class-level
866
        foreach (array_reverse(['class', 'method']) as $level) {
76✔
867
            foreach ($this->routeAttributes[$level] as $attribute) {
76✔
868
                if ($attribute instanceof RouteAttributeInterface) {
5✔
869
                    $result = $attribute->after($request, $response);
5✔
870

871
                    if ($result instanceof ResponseInterface) {
5✔
872
                        $response = $result;
2✔
873
                    }
874
                }
875
            }
876
        }
877

878
        return $response;
76✔
879
    }
880

881
    /**
882
     * Returns the route attributes collected during routing
883
     * for the current route.
884
     *
885
     * @return array{class: list<string>, method: list<string>}
886
     */
887
    public function getRouteAttributes(): array
888
    {
NEW
889
        return $this->routeAttributes;
×
890
    }
891
}
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