Skip to content

Commit 5f104f6

Browse files
datamwebmichalsn
andauthored
feat: allow overriding namespaced views via app/Views directory (#9860)
* feat: allow overriding namespaced views via app/Views * tests: add tests for namespaced view overrides * docs: document namespaced view overriding * docs: fix explicit markup ends without a blank line * refactor: optimize namespaced view path resolution Co-authored-by: Michal Sniatala <michal@sniatala.pl> * style: fix code style * Update system/View/View.php Co-authored-by: Michal Sniatala <michal@sniatala.pl> * Update user_guide_src/source/outgoing/views.rst Co-authored-by: Michal Sniatala <michal@sniatala.pl> * fix: conflicts resolved * tests: use ViewConfig alias instead of Config\View --------- Co-authored-by: Michal Sniatala <michal@sniatala.pl>
1 parent 2226f89 commit 5f104f6

File tree

5 files changed

+156
-5
lines changed

5 files changed

+156
-5
lines changed

app/Config/View.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,21 @@ class View extends BaseView
5959
* @var list<class-string<ViewDecoratorInterface>>
6060
*/
6161
public array $decorators = [];
62+
63+
/**
64+
* Subdirectory within app/Views for namespaced view overrides.
65+
*
66+
* Namespaced views will be searched in:
67+
*
68+
* app/Views/{$appOverridesFolder}/{Namespace}/{view_path}.{php|html...}
69+
*
70+
* This allows application-level overrides for package or module views
71+
* without modifying vendor source files.
72+
*
73+
* Examples:
74+
* 'overrides' -> app/Views/overrides/Example/Blog/post/card.php
75+
* 'vendor' -> app/Views/vendor/Example/Blog/post/card.php
76+
* '' -> app/Views/Example/Blog/post/card.php (direct mapping)
77+
*/
78+
public string $appOverridesFolder = 'overrides';
6279
}

system/View/View.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,18 @@ public function render(string $view, ?array $options = null, ?bool $saveData = n
201201

202202
$this->renderVars['file'] = $this->viewPath . $this->renderVars['view'];
203203

204+
if (str_contains($this->renderVars['view'], '\\')) {
205+
$overrideFolder = $this->config->appOverridesFolder !== ''
206+
? trim($this->config->appOverridesFolder, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR
207+
: '';
208+
209+
$this->renderVars['file'] = $this->viewPath
210+
. $overrideFolder
211+
. ltrim(str_replace('\\', DIRECTORY_SEPARATOR, $this->renderVars['view']), DIRECTORY_SEPARATOR);
212+
} else {
213+
$this->renderVars['file'] = $this->viewPath . $this->renderVars['view'];
214+
}
215+
204216
if (! is_file($this->renderVars['file'])) {
205217
$this->renderVars['file'] = $this->loader->locateFile(
206218
$this->renderVars['view'],

tests/system/View/ViewTest.php

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
use CodeIgniter\Exceptions\RuntimeException;
1818
use CodeIgniter\Test\CIUnitTestCase;
1919
use CodeIgniter\View\Exceptions\ViewException;
20-
use Config;
20+
use Config\View as ViewConfig;
2121
use PHPUnit\Framework\Attributes\Group;
2222

2323
/**
@@ -28,15 +28,16 @@ final class ViewTest extends CIUnitTestCase
2828
{
2929
private FileLocatorInterface $loader;
3030
private string $viewsDir;
31-
private Config\View $config;
31+
private ViewConfig $config;
3232

3333
protected function setUp(): void
3434
{
3535
parent::setUp();
3636

37-
$this->loader = service('locator');
38-
$this->viewsDir = __DIR__ . '/Views';
39-
$this->config = new Config\View();
37+
$this->loader = service('locator');
38+
$this->viewsDir = __DIR__ . '/Views';
39+
$this->config = new ViewConfig();
40+
$this->config->appOverridesFolder = '';
4041
}
4142

4243
public function testSetVarStoresData(): void
@@ -413,4 +414,76 @@ public function testViewExcerpt(): void
413414
$this->assertSame('CodeIgniter is a PHP full-stack web framework...', $view->excerpt('CodeIgniter is a PHP full-stack web framework that is light, fast, flexible and secure.', 48));
414415
$this->assertSame('CodeIgniter - это полнофункциональный веб-фреймворк...', $view->excerpt('CodeIgniter - это полнофункциональный веб-фреймворк на PHP, который является легким, быстрым, гибким и безопасным.', 54));
415416
}
417+
418+
public function testRenderNamespacedViewPriorityToAppViews(): void
419+
{
420+
$loader = $this->createMock(FileLocatorInterface::class);
421+
$loader->expects($this->never())->method('locateFile');
422+
423+
$view = new View($this->config, $this->viewsDir, $loader);
424+
425+
$view->setVar('testString', 'Hello World');
426+
$expected = '<h1>Hello World</h1>';
427+
428+
$output = $view->render('Nested\simple');
429+
430+
$this->assertStringContainsString($expected, $output);
431+
}
432+
433+
public function testRenderNamespacedViewFallsBackToLoader(): void
434+
{
435+
$namespacedView = 'Some\Library\View';
436+
437+
$realFile = $this->viewsDir . '/simple.php';
438+
439+
$loader = $this->createMock(FileLocatorInterface::class);
440+
$loader->expects($this->once())
441+
->method('locateFile')
442+
->with($namespacedView . '.php', 'Views', 'php')
443+
->willReturn($realFile);
444+
445+
$view = new View($this->config, $this->viewsDir, $loader);
446+
447+
$view->setVar('testString', 'Hello World');
448+
$output = $view->render($namespacedView);
449+
450+
$this->assertStringContainsString('<h1>Hello World</h1>', $output);
451+
}
452+
453+
public function testRenderNamespacedViewWithExplicitExtension(): void
454+
{
455+
$namespacedView = 'Some\Library\View.html';
456+
457+
$realFile = $this->viewsDir . '/simple.php';
458+
459+
$loader = $this->createMock(FileLocatorInterface::class);
460+
$loader->expects($this->once())
461+
->method('locateFile')
462+
->with($namespacedView, 'Views', 'html')
463+
->willReturn($realFile);
464+
465+
$view = new View($this->config, $this->viewsDir, $loader);
466+
$view->setVar('testString', 'Hello World');
467+
468+
$view->render($namespacedView);
469+
}
470+
471+
public function testOverrideWithCustomFolderChecksSubdirectory(): void
472+
{
473+
$this->config->appOverridesFolder = 'overrides';
474+
475+
$loader = $this->createMock(FileLocatorInterface::class);
476+
$loader->expects($this->once())
477+
->method('locateFile')
478+
->with('Nested\simple.php', 'Views', 'php')
479+
->willReturn($this->viewsDir . '/simple.php');
480+
481+
$view = new View($this->config, $this->viewsDir, $loader);
482+
483+
$view->setVar('testString', 'Fallback Content');
484+
485+
$output = $view->render('Nested\simple');
486+
487+
$this->assertStringContainsString('<h1>Fallback Content</h1>', $output);
488+
}
416489
}

user_guide_src/source/changelogs/v4.7.0.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ Libraries
191191
- **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() <api_response_trait_paginate>` for details.
192192
- **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()``
193193
- **Time:** Added ``Time::isPast()`` and ``Time::isFuture()`` convenience methods. See :ref:`isPast <time-comparing-two-times-isPast>` and :ref:`isFuture <time-comparing-two-times-isFuture>` for details.
194+
- **View:** Added the ability to override namespaced views (e.g., from modules/packages) by placing a matching file structure within the **app/Views/overrides** directory. See :ref:`Overriding Namespaced Views <views-overriding-namespaced-views>` for details.
195+
194196

195197
Commands
196198
========

user_guide_src/source/outgoing/views.rst

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,53 @@ example, you could load the **blog_view.php** file from **example/blog/Views** b
104104

105105
.. literalinclude:: views/005.php
106106

107+
.. _views-overriding-namespaced-views:
108+
109+
Overriding Namespaced Views
110+
===========================
111+
112+
.. versionadded:: 4.7.0
113+
114+
You can override a namespaced view by creating a matching directory structure within your application's **app/Views** directory.
115+
This allows you to customize the output of modules or packages without modifying their core source code.
116+
117+
Configuration
118+
-------------
119+
120+
By default, overrides are looked for in the **app/Views/overrides** directory. You can configure this location via the ``$appOverridesFolder`` property in **app/Config/View.php**:
121+
122+
.. code-block:: php
123+
124+
public string $appOverridesFolder = 'overrides';
125+
126+
If you prefer to map namespaces directly to the root of **app/Views** (without a subdirectory), you can set this value to an empty string (``''``).
127+
128+
Example
129+
-------
130+
131+
Assume you have a module named **Blog** with the namespace ``Example\Blog``. The original view file is located at:
132+
133+
.. code-block:: text
134+
135+
/modules
136+
└── Example
137+
└── Blog
138+
└── Views
139+
└── blog_view.php
140+
141+
To override this view (using the default configuration), create a file at the matching path within **app/Views/overrides**:
142+
143+
.. code-block:: text
144+
145+
/app
146+
└── Views
147+
└── overrides <-- Configured $appOverridesFolder
148+
└── Example <-- Matches the first part of namespace
149+
└── Blog <-- Matches the second part of namespace
150+
└── blog_view.php <-- Your custom view
151+
152+
Now, when you call ``view('Example\Blog\blog_view')``, CodeIgniter will automatically load your custom view from **app/Views/overrides/Example/Blog/blog_view.php** instead of the original module view file.
153+
107154
.. _caching-views:
108155

109156
Caching Views

0 commit comments

Comments
 (0)