Skip to content
17 changes: 17 additions & 0 deletions app/Config/View.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,21 @@ class View extends BaseView
* @var list<class-string<ViewDecoratorInterface>>
*/
public array $decorators = [];

/**
* Subdirectory within app/Views for namespaced view overrides.
*
* Namespaced views will be searched in:
*
* app/Views/{$appOverridesFolder}/{Namespace}/{view_path}.{php|html...}
*
* This allows application-level overrides for package or module views
* without modifying vendor source files.
*
* Examples:
* 'overrides' -> app/Views/overrides/Example/Blog/post/card.php
* 'vendor' -> app/Views/vendor/Example/Blog/post/card.php
* '' -> app/Views/Example/Blog/post/card.php (direct mapping)
*/
public string $appOverridesFolder = 'overrides';
}
12 changes: 12 additions & 0 deletions system/View/View.php
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,18 @@ public function render(string $view, ?array $options = null, ?bool $saveData = n

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

if (str_contains($this->renderVars['view'], '\\')) {
$overrideFolder = $this->config->appOverridesFolder !== ''
? trim($this->config->appOverridesFolder, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR
: '';

$this->renderVars['file'] = $this->viewPath
. $overrideFolder
. ltrim(str_replace('\\', DIRECTORY_SEPARATOR, $this->renderVars['view']), DIRECTORY_SEPARATOR);
} else {
$this->renderVars['file'] = $this->viewPath . $this->renderVars['view'];
}

if (! is_file($this->renderVars['file'])) {
$this->renderVars['file'] = $this->loader->locateFile(
$this->renderVars['view'],
Expand Down
83 changes: 78 additions & 5 deletions tests/system/View/ViewTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
use CodeIgniter\Exceptions\RuntimeException;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\View\Exceptions\ViewException;
use Config;
use Config\View as ViewConfig;
use PHPUnit\Framework\Attributes\Group;

/**
Expand All @@ -28,15 +28,16 @@ final class ViewTest extends CIUnitTestCase
{
private FileLocatorInterface $loader;
private string $viewsDir;
private Config\View $config;
private ViewConfig $config;

protected function setUp(): void
{
parent::setUp();

$this->loader = service('locator');
$this->viewsDir = __DIR__ . '/Views';
$this->config = new Config\View();
$this->loader = service('locator');
$this->viewsDir = __DIR__ . '/Views';
$this->config = new ViewConfig();
$this->config->appOverridesFolder = '';
}

public function testSetVarStoresData(): void
Expand Down Expand Up @@ -413,4 +414,76 @@ public function testViewExcerpt(): void
$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));
$this->assertSame('CodeIgniter - это полнофункциональный веб-фреймворк...', $view->excerpt('CodeIgniter - это полнофункциональный веб-фреймворк на PHP, который является легким, быстрым, гибким и безопасным.', 54));
}

public function testRenderNamespacedViewPriorityToAppViews(): void
{
$loader = $this->createMock(FileLocatorInterface::class);
$loader->expects($this->never())->method('locateFile');

$view = new View($this->config, $this->viewsDir, $loader);

$view->setVar('testString', 'Hello World');
$expected = '<h1>Hello World</h1>';

$output = $view->render('Nested\simple');

$this->assertStringContainsString($expected, $output);
}

public function testRenderNamespacedViewFallsBackToLoader(): void
{
$namespacedView = 'Some\Library\View';

$realFile = $this->viewsDir . '/simple.php';

$loader = $this->createMock(FileLocatorInterface::class);
$loader->expects($this->once())
->method('locateFile')
->with($namespacedView . '.php', 'Views', 'php')
->willReturn($realFile);

$view = new View($this->config, $this->viewsDir, $loader);

$view->setVar('testString', 'Hello World');
$output = $view->render($namespacedView);

$this->assertStringContainsString('<h1>Hello World</h1>', $output);
}

public function testRenderNamespacedViewWithExplicitExtension(): void
{
$namespacedView = 'Some\Library\View.html';

$realFile = $this->viewsDir . '/simple.php';

$loader = $this->createMock(FileLocatorInterface::class);
$loader->expects($this->once())
->method('locateFile')
->with($namespacedView, 'Views', 'html')
->willReturn($realFile);

$view = new View($this->config, $this->viewsDir, $loader);
$view->setVar('testString', 'Hello World');

$view->render($namespacedView);
}

public function testOverrideWithCustomFolderChecksSubdirectory(): void
{
$this->config->appOverridesFolder = 'overrides';

$loader = $this->createMock(FileLocatorInterface::class);
$loader->expects($this->once())
->method('locateFile')
->with('Nested\simple.php', 'Views', 'php')
->willReturn($this->viewsDir . '/simple.php');

$view = new View($this->config, $this->viewsDir, $loader);

$view->setVar('testString', 'Fallback Content');

$output = $view->render('Nested\simple');

$this->assertStringContainsString('<h1>Fallback Content</h1>', $output);
}
}
2 changes: 2 additions & 0 deletions user_guide_src/source/changelogs/v4.7.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ Libraries
- **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() <api_response_trait_paginate>` for details.
- **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()``
- **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.
- **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.


Commands
========
Expand Down
47 changes: 47 additions & 0 deletions user_guide_src/source/outgoing/views.rst
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,53 @@ example, you could load the **blog_view.php** file from **example/blog/Views** b

.. literalinclude:: views/005.php

.. _views-overriding-namespaced-views:

Overriding Namespaced Views
===========================

.. versionadded:: 4.7.0

You can override a namespaced view by creating a matching directory structure within your application's **app/Views** directory.
This allows you to customize the output of modules or packages without modifying their core source code.

Configuration
-------------

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**:

.. code-block:: php

public string $appOverridesFolder = 'overrides';

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 (``''``).

Example
-------

Assume you have a module named **Blog** with the namespace ``Example\Blog``. The original view file is located at:

.. code-block:: text

/modules
└── Example
└── Blog
└── Views
└── blog_view.php

To override this view (using the default configuration), create a file at the matching path within **app/Views/overrides**:

.. code-block:: text

/app
└── Views
└── overrides <-- Configured $appOverridesFolder
└── Example <-- Matches the first part of namespace
└── Blog <-- Matches the second part of namespace
└── blog_view.php <-- Your custom view

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.

.. _caching-views:

Caching Views
Expand Down
Loading