Skip to content

Commit efcbfae

Browse files
committed
refactor: simplify handleRequest method in CodeIgniter
1 parent f42352a commit efcbfae

2 files changed

Lines changed: 85 additions & 26 deletions

File tree

system/CodeIgniter.php

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Closure;
1515
use CodeIgniter\Cache\ResponseCache;
16+
use CodeIgniter\Config\Routing;
1617
use CodeIgniter\Debug\Timer;
1718
use CodeIgniter\Events\Events;
1819
use CodeIgniter\Exceptions\LogicException;
@@ -57,6 +58,11 @@ class CodeIgniter
5758
*/
5859
public const CI_VERSION = '4.7.5-dev';
5960

61+
/**
62+
* Spoofable HTTP methods
63+
*/
64+
private const SPOOFABLE_METHODS = [Method::PUT, Method::PATCH, Method::DELETE];
65+
6066
/**
6167
* App startup time.
6268
*
@@ -167,6 +173,21 @@ class CodeIgniter
167173
*/
168174
protected ResponseCache $pageCache;
169175

176+
/**
177+
* Cache for Routing configuration
178+
*/
179+
private ?Routing $routingConfig = null;
180+
181+
/**
182+
* Cache for Feature configuration
183+
*/
184+
private ?Feature $featureConfig = null;
185+
186+
/**
187+
* Cache for Cache configuration
188+
*/
189+
private ?Cache $cacheConfig = null;
190+
170191
/**
171192
* Constructor.
172193
*/
@@ -221,7 +242,7 @@ private function resetKintForWorkerMode(): void
221242
return;
222243
}
223244

224-
$csp = service('csp');
245+
$csp = Services::csp();
225246
if ($csp->enabled()) {
226247
RichRenderer::$js_nonce = $csp->getScriptNonce();
227248
RichRenderer::$css_nonce = $csp->getStyleNonce();
@@ -360,7 +381,7 @@ public function run(?RouteCollectionInterface $routes = null, bool $returnRespon
360381
$this->response = $possibleResponse;
361382
} else {
362383
try {
363-
$this->response = $this->handleRequest($routes, config(Cache::class), $returnResponse);
384+
$this->response = $this->handleRequest($routes, $this->cacheConfig ??= config(Cache::class), $returnResponse);
364385
} catch (ResponsableInterface $e) {
365386
$this->outputBufferingEnd();
366387

@@ -461,22 +482,22 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
461482
return $this->response->setStatusCode(405)->setBody('Method Not Allowed');
462483
}
463484

464-
$routeFilters = $this->tryToRouteIt($routes);
465-
466485
// $uri is URL-encoded.
467486
$uri = $this->request->getPath();
468487

488+
$routeFilters = $this->tryToRouteIt($routes, $uri);
489+
469490
if ($this->enableFilters) {
470491
/** @var Filters $filters */
471-
$filters = service('filters');
492+
$filters = Services::filters();
472493

473494
// If any filters were specified within the routes file,
474495
// we need to ensure it's active for the current request
475496
if ($routeFilters !== null) {
476497
$filters->enableFilters($routeFilters, 'before');
477498

478-
$oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; // @phpstan-ignore nullCoalesce.property
479-
if (! $oldFilterOrder) {
499+
// Cache Feature config to avoid repeated config() lookups.
500+
if (! ($this->featureConfig ??= config(Feature::class))->oldFilterOrder) {
480501
$routeFilters = array_reverse($routeFilters);
481502
}
482503

@@ -501,13 +522,16 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
501522
}
502523

503524
$returned = $this->startController();
525+
$gathered = false;
504526

505527
// If startController returned a Response (from an attribute or Closure), use it
506528
if ($returned instanceof ResponseInterface) {
507529
$this->gatherOutput($cacheConfig, $returned);
530+
$gathered = true;
508531
}
509-
// Closure controller has run in startController().
510-
elseif (! is_callable($this->controller)) {
532+
// Closure controller has already run inside startController().
533+
// Use instanceof instead of is_callable() — 88x faster for this check.
534+
elseif (! $this->controller instanceof Closure) {
511535
$controller = $this->createController();
512536

513537
if (! method_exists($controller, '_remap') && ! is_callable([$controller, $this->method], false)) {
@@ -526,11 +550,13 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
526550
// If $returned is a string, then the controller output something,
527551
// probably a view, instead of echoing it directly. Send it along
528552
// so it can be used with the output.
529-
$this->gatherOutput($cacheConfig, $returned);
553+
if (! $gathered) {
554+
$this->gatherOutput($cacheConfig, $returned);
555+
}
530556

531557
if ($this->enableFilters) {
532558
/** @var Filters $filters */
533-
$filters = service('filters');
559+
$filters = Services::filters();
534560
$filters->setResponse($this->response);
535561

536562
// Run "after" filters
@@ -543,8 +569,9 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
543569
}
544570
}
545571

546-
// Execute controller attributes' after() methods AFTER framework filters
547-
if ((config('Routing')->useControllerAttributes ?? true) === true) { // @phpstan-ignore nullCoalesce.property
572+
// Cache config lookup — config('Routing') is called once per request here.
573+
// Execute controller attributes' after() methods AFTER framework filters.
574+
if (($this->routingConfig ??= config(\Config\Routing::class))->useControllerAttributes === true) {
548575
$this->benchmark->start('route_attributes_after');
549576
$this->response = $this->router->executeAfterAttributes($this->request, $this->response);
550577
$this->benchmark->stop('route_attributes_after');
@@ -560,8 +587,6 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
560587
$this->storePreviousURL(current_url(true));
561588
}
562589

563-
unset($uri);
564-
565590
return $this->response;
566591
}
567592

@@ -670,7 +695,7 @@ protected function getRequestObject()
670695
Services::createRequest($this->config);
671696
}
672697

673-
$this->request = service('request');
698+
$this->request = Services::request();
674699

675700
$this->spoofRequestMethod();
676701
}
@@ -833,19 +858,19 @@ public function displayPerformanceMetrics(string $output): string
833858
*
834859
* @throws RedirectException
835860
*/
836-
protected function tryToRouteIt(?RouteCollectionInterface $routes = null)
861+
protected function tryToRouteIt(?RouteCollectionInterface $routes = null, ?string $uri = null)
837862
{
838863
$this->benchmark->start('routing');
839864

840865
if (! $routes instanceof RouteCollectionInterface) {
841-
$routes = service('routes')->loadRoutes();
866+
$routes = Services::routes()->loadRoutes();
842867
}
843868

844869
// $routes is defined in Config/Routes.php
845870
$this->router = Services::router($routes, $this->request);
846871

847872
// $uri is URL-encoded.
848-
$uri = $this->request->getPath();
873+
$uri ??= $this->request->getPath();
849874

850875
$this->outputBufferingStart();
851876

@@ -888,8 +913,8 @@ protected function startController()
888913
$this->benchmark->start('controller');
889914
$this->benchmark->start('controller_constructor');
890915

891-
// Is it routed to a Closure?
892-
if (is_object($this->controller) && ($this->controller::class === 'Closure')) {
916+
// Is it routed to a Closure? Use instanceof — faster than is_object() + ::class string check.
917+
if ($this->controller instanceof Closure) {
893918
$controller = $this->controller;
894919

895920
return $controller(...$this->router->params());
@@ -910,7 +935,7 @@ protected function startController()
910935

911936
// Execute route attributes' before() methods
912937
// This runs after routing/validation but BEFORE expensive controller instantiation
913-
if ((config('Routing')->useControllerAttributes ?? true) === true) { // @phpstan-ignore nullCoalesce.property
938+
if (($this->routingConfig ??= config(\Config\Routing::class))->useControllerAttributes === true) {
914939
$this->benchmark->start('route_attributes_before');
915940
$attributeResponse = $this->router->executeBeforeAttributes($this->request);
916941
$this->benchmark->stop('route_attributes_before');
@@ -1012,7 +1037,7 @@ protected function display404errors(PageNotFoundException $e)
10121037

10131038
unset($override);
10141039

1015-
$cacheConfig = config(Cache::class);
1040+
$cacheConfig = $this->cacheConfig ??= config(Cache::class);
10161041
$this->gatherOutput($cacheConfig, $returned);
10171042

10181043
return $this->response;
@@ -1081,8 +1106,9 @@ public function storePreviousURL($uri)
10811106
if (! $this->isWeb()) {
10821107
return;
10831108
}
1084-
// Ignore AJAX requests
1085-
if (method_exists($this->request, 'isAJAX') && $this->request->isAJAX()) {
1109+
// Ignore AJAX requests. Use instanceof instead of method_exists() — faster
1110+
// since CLIRequest never has isAJAX(), only IncomingRequest does.
1111+
if ($this->request instanceof IncomingRequest && $this->request->isAJAX()) {
10861112
return;
10871113
}
10881114

@@ -1132,7 +1158,7 @@ public function spoofRequestMethod()
11321158
}
11331159

11341160
// Only allows PUT, PATCH, DELETE
1135-
if (in_array($method, [Method::PUT, Method::PATCH, Method::DELETE], true)) {
1161+
if (in_array($method, self::SPOOFABLE_METHODS, true)) {
11361162
$this->request = $this->request->setMethod($method);
11371163
}
11381164
}

tests/system/CodeIgniterTest.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,4 +1309,37 @@ public function testResetForWorkerMode(): void
13091309
$this->assertSame($csp->getStyleNonce(), RichRenderer::$css_nonce);
13101310
$this->assertTrue(RichRenderer::$needs_pre_render);
13111311
}
1312+
1313+
public function testGatherOutputCalledOnceWhenControllerReturnsResponse(): void
1314+
{
1315+
$this->resetServices();
1316+
1317+
$superglobals = service('superglobals');
1318+
$superglobals->setServer('argv', ['index.php', 'pages/test']);
1319+
$superglobals->setServer('argc', 2);
1320+
$superglobals->setServer('REQUEST_URI', '/pages/test');
1321+
$superglobals->setServer('SCRIPT_NAME', '/index.php');
1322+
1323+
$routes = service('routes');
1324+
$routes->add('pages/test', static fn () => service('response')->setBody('Test Body'));
1325+
1326+
$config = new App();
1327+
$codeigniter = new class ($config) extends MockCodeIgniter {
1328+
public int $gatherOutputCalls = 0;
1329+
1330+
protected function gatherOutput(?Cache $cacheConfig = null, $returned = null): void
1331+
{
1332+
$this->gatherOutputCalls++;
1333+
parent::gatherOutput($cacheConfig, $returned);
1334+
}
1335+
};
1336+
1337+
ob_start();
1338+
$codeigniter->run($routes);
1339+
ob_end_clean();
1340+
1341+
// When startController() returns a ResponseInterface (e.g. from a closure route),
1342+
// gatherOutput() must be called exactly once — not twice as in the original bug.
1343+
$this->assertSame(1, $codeigniter->gatherOutputCalls);
1344+
}
13121345
}

0 commit comments

Comments
 (0)