These six were all found in a single application, over a single week, and they have one thing in common: none of them produces an error that points at the mistake. Four produce no error at all. One produces a blank 500 whose stack trace blames the wrong line. One leaves a page that still looks styled while a rule you wrote is quietly missing.
They are ordered by how hard they are to notice, hardest first. If you are auditing an existing application rather than reading straight through, the last section has two scripts that find the first two mechanically.
1. A <style> tag inside Output::headCss eats exactly one CSS rule
This is the worst of the six, because the page still looks styled. Nothing is blank, nothing errors, and the browser console says nothing. One rule — always the first one in the block — simply does not apply.
The symptom
A page that should be a grid renders as a tall stack of full-width blocks. The rest of the styling — colours, spacing, borders, typography — is all correct, which is exactly what makes the stylesheet the last thing anybody suspects. Inspecting the element shows your class present on the element and the rule absent from the cascade, as though you had never written it.
Why it happens
Core already supplies the element. In applications/core/dev/html/global/global/includeCSS.phtml:
{{if \IPS\Output::i()->headCss}}
<style id="headCSS">
{expression="\IPS\Output::i()->headCss" raw="true"}
</style>
{{endif}}
So if the value you assign already contains <style>, the browser is handed <style><style>.foo{...}. Inside a <style> element the content is CSS, not HTML, so the inner tag is not a tag at all — it is a stray < where the CSS parser expected a selector.
The CSS parser does not discard the whole stylesheet. It recovers, and the way it recovers is to treat the junk as the prelude of a qualified rule and consume up to and including the next { … } block. That block is your first rule. Everything after it parses normally and applies.
So the failure is one rule wide. In the application where this was found it cost three rules across three pages, and each one happened to be the rule that established the layout — a display:grid and two display:flex declarations. Every check that looked at the text of the page passed.
The fix
Return the CSS on its own. Core supplies the element:
/* wrong */
Output::i()->headCss .= '<style>.myapp-grid{display:grid}</style>';
/* right */
Output::i()->headCss .= '.myapp-grid{display:grid}';
A useful sanity check while you are in that template: headCss is emitted inside a {{if \IPS\Dispatcher::i()->controllerLocation == 'front' …}} branch, so anything you put in headCss from the AdminCP is discarded. That is a second silent failure hiding in the same few lines.
One caveat when you go looking for this in your own code: a plain file-level search for <style is useless. Pop-out windows and standalone HTML documents legitimately need a real <style> tag, so a naive search flags correct code, and a check that cries wolf gets switched off. Only the value that reaches headCss matters.
2. use IPS\Settings; in a file that declares class settings
The symptom
One AdminCP page returns a bare 500 with no IPS error screen and no error code. If a stack trace is produced at all, it names the class line of your controller. The use statement that actually caused it is never mentioned.
Cannot declare class IPS\myapp\modules\admin\settings\settings, because the name is already in use
Why it happens
PHP compares class names case-insensitively, and an import occupies a name in the file's symbol table exactly as a declaration does. These two lines therefore claim the same name:
namespace IPS\myapp\modules\admin\settings;
use IPS\Settings; /* imports the name "Settings" */
class settings extends Controller /* declares the name "settings" */
{
It is an E_COMPILE_ERROR, which means it happens before any of your code runs. No try/catch reaches it, IPS's error handler never gets the chance to render its screen, and the shutdown handler is all that is left — which is why the trace is so unhelpful.
It bites in Invision Community specifically because a controller is named after its module, so the class name is lowercase and matches an ordinary IPS class. Any application with a modules/admin/settings/settings.php is one use IPS\Settings; away from it, and Settings is about the most natural thing to want in a settings controller. The same trap is waiting in a members/members.php, a forums/forums.php, and so on.
The fix
Drop the import and write the class out in full at the point of use:
/* no import for Settings */
class settings extends Controller
{
public function manage(): void
{
$value = \IPS\Settings::i()->myapp_thing;
}
}
Aliasing (use IPS\Settings as SettingsClass;) also works, because the imported name becomes the alias. Writing it in full is clearer about why the import is missing.
Not to be confused with a trait
The same keyword means something else inside a class body, and there it is legal even when the names match exactly:
class Xenforo extends Software
{
use \IPS\convert\Tools\Xenforo; /* composes a trait — fine */
That is real core code, in applications/convert/sources/Software/Forums/Xenforo.php, and it ships and works. A trait use composes behaviour into a class; it does not import a name into the file. If you write a checker for this, cut the file at the class keyword before you look for imports, or your very first run will report core as broken.
3. Application::init() runs for one application per request
The symptom
Something registered in your application's init() — a listener, a hook, a global asset, a navigation item — works when you are inside your own application's pages and is absent everywhere else on the site. Nothing errors; the code simply never ran.
Why it happens
init() is called by the dispatcher, on the one application that is handling the request, in system/Dispatcher/Standard.php (around line 230):
if ( method_exists( $this->application, 'init' ) )
{
$this->application->init();
}
$this->application is singular by design — it is whichever application owns the URL being served. Viewing a forum topic initialises forums, and your application's init() is not called. It is an application initialiser, not a site initialiser, and the name invites the wrong assumption.
The fix
Anything site-wide belongs in a core/Loader extension, which core iterates over every request regardless of which application is dispatching:
foreach( Application::allExtensions( 'core', 'Loader' ) as $loader )
{
foreach( $loader->js() as $js ) { … }
foreach( $loader->css() as $css ) { … }
}
…in both system/Dispatcher/Front.php and system/Dispatcher/Admin.php.
🚨 That last part is the follow-on trap. The Loader also runs in the AdminCP, so moving your front-end assets there will start injecting them into every AdminCP page. Gate on the location:
if ( Dispatcher::i()->controllerLocation !== 'front' )
{
return array();
}
4. A content item is never indexed without a core/SearchContent extension
The symptom
Your content items exist, render, and are reachable by URL, but searching for their titles returns nothing. core_search_index holds no rows for your class. No exception is thrown at any point, and rebuilding the search index does not help.
Why it happens
Getting the content behaviours right is not enough. A complete set of traits, a correct ContentRouter extension, working permissions — none of it registers your class with search. Only a core/SearchContent extension naming the class in its supportedClasses() does that, and everything downstream checks membership of that list before doing anything:
/* system/Content/Search/SearchContent.php */
public static function isSearchable( string|object $object ) : bool
{
$class = is_string( $object ) ? $object : get_class( $object );
return in_array( $class, static::searchableClasses() );
}
The consumers all return rather than throw, which is where the silence comes from. In system/Content/Search/Index.php, indexData() returns NULL and indexSingleItem() returns early:
if( !SearchContent::isSearchable( $object ) )
{
return;
}
So you can call the indexer as often as you like on an unregistered class and get a clean no-op every time.
The second half: save() does not index
Even with the extension in place, a hand-built item stays unindexed. Item::save() does not touch the search index. The call lives in processAfterCreate() (system/Content/Item.php, around line 1019):
protected function processAfterCreate( Comment|null $comment, array $values ): void
{
if ( Bridge::i()->checkItemForSpam( $this ) ) { return; }
/* Add to search index */
if( Content\Search\SearchContent::isSearchable( $this ) )
{
Index::i()->index( $this );
}
…and processAfterCreate() is reached through createFromForm() (same file, around line 568). Content created by a form is indexed. Content you build in code, in an importer, in a task, or in a seeding script is not, because you never went through createFromForm(). Index it explicitly:
$item->save(); Index::i()->index( $item );
Note the spam check ahead of the indexing call: an item flagged as spam returns early and is correctly never indexed. If one specific item is missing from search while its neighbours are fine, look there before you look at your extension.
5. Unprefixed column names against a prefixed table, inside a try/catch
The symptom
A log table, a statistics table or an audit trail is empty. Not sparse — empty, for the entire life of the feature. Nothing in the error log, and the feature it belongs to works perfectly.
Why it happens
Two ordinary decisions combine into an invisible one. IPS convention prefixes columns with the table's short name, so a table called airplay has airplay_title, not title. And code that writes a log is quite reasonably wrapped so that a broken log never costs somebody their page view:
try
{
Db::i()->insert( 'myapp_airplay', array(
'played' => time(), /* column is airplay_played */
'title' => $title, /* column is airplay_title */
) );
}
catch ( Exception $e ) {}
Every insert throws "Unknown column", and every one of them is swallowed by the catch that exists to protect the visitor. The guard is right; it is the total absence of any other signal that is wrong.
The fix
Keep the catch — do not trade a silent log for a broken page — but make it say something:
catch ( Exception $e )
{
Log::log( $e, 'myapp_airplay' );
}
Then verify the table is actually receiving rows before you call the feature done. A SELECT COUNT(*) against every table your application writes to, as part of your own post-release check, catches this class of bug in seconds. An empty table is the assertion; the code reading correctly is not.
The general form is worth stating plainly, because it is not limited to logs: a catch with an empty body converts a fatal error into a missing feature, and a missing feature is much harder to notice than a crash.
6. Generated FileStorage extension stubs left unfilled
The symptom
Everything works until an administrator moves file storage to S3 or another configuration, at which point the move throws. Separately, and before that, the orphaned-file sweep reports every file your application owns as unclaimed and offers to delete them.
Why it happens
The generated skeleton is deliberately non-functional, and the placeholders are strings, so nothing complains at build time. From applications/core/data/defaults/extensions/FileStorage.txt:
public function count(): int
{
return Db::i()->select( 'COUNT(*)', '...', '...' )->first();
}
public function move( int $offset, int $storageConfiguration, int $oldConfiguration=NULL ) : void
{
SystemFile::get( $oldConfiguration ?: '{app}_{class}', '...' )->move( $storageConfiguration );
}
public function isValidFile( SystemFile|string $file ): bool
{
return FALSE;
}
public function delete() : void
{
}
Note isValidFile() returning FALSE. That is the answer to "is this file one of mine?", so an unfilled stub tells the orphaned-file sweep that none of your files are claimed. And delete() is an empty body, so uninstalling your application leaves every file it ever stored on disk.
The fix
Fill in all four with the real table and column, and treat the presence of '...' anywhere under extensions/core/FileStorage/ as a release blocker:
grep -rn "'\.\.\.'" applications/myapp/extensions/core/FileStorage/
An empty delete() is harder to catch that way, since an empty body is also what a genuinely file-less extension would have. If your application stores files at all, that method has a body.
Three smaller ones worth knowing
A node with no permission row is invisible, and so is its content
Node permissions live in a separate table, core_permission_index, and are not created by saving the node. Node\Model::roots() filters with a subquery (system/Node/Model.php, around line 544):
$where[] = array(
'(' . static::$databaseTable . '.' . … . ' IN( '
. Db::i()->select( 'perm_type_id', 'core_permission_index', $permQueryWhere )->returnFullQuery()
. ') )',
);
A node with no row is simply not in that set, so it does not appear — with no error, and while looking perfectly correct in the database. Call setPermissions() after creating a node in code.
The same fact reaches search from the other side. The MySQL indexer removes, rather than skips, anything whose permissions come out empty (system/Content/Search/Mysql/Index.php, around line 85):
/* If nobody has permission to access it, just remove it */
if ( !$indexData['index_permissions'] )
{
$this->removeFromSearchIndex( $object );
}
So a permissions mistake does not leave stale rows you can find and puzzle over. It deletes the evidence.
core_search_index belongs to the MySQL driver only
If you write a test that asserts on core_search_index, it will pass or fail for the wrong reason on an Elasticsearch site. The driver is chosen at runtime in system/Content/Search/Index.php:
if ( Settings::i()->search_method == 'elastic' )
{
static::$instance = new Elastic\Index( Elastic\Index::elasticBaseUrl() );
}
else
{
static::$instance = new Mysql\Index;
}
Ask get_class( Index::i() ) first and skip the table assertion when it is not IPS\Content\Search\Mysql\Index. A test that silently checks an unused table is worse than no test.
IPS refuses to render a 200 at a URL carrying a CSRF key
In developer mode, Output::sendOutput() raises E_USER_ERROR rather than send the page:
An 200 response is being sent however the CSRF key is present in the requested URL. CSRF keys should be sent via POST or the request should be redirected to a URL not containing a CSRF key once finished.
The check is IN_DEV-only (system/Output/Output.php, around line 1079) and excludes AJAX requests and file downloads. So an action that renders a confirmation page directly instead of redirecting will look fine on a production site and hard-fail on the developer's machine — or the reverse, if you only ever test in developer mode and never see it because you always redirect. Redirect after the action; do not render.
Two checks you can reuse
The first two are mechanical, and both are worth running over an application you did not write. Neither needs IPS to be bootstrapped — they read source, so you can run them on a build directory or in CI.
Import/declaration collisions. Find every class whose file imports its own name. The rule is a case-insensitive comparison between the declared name and each imported name (the alias, where there is one) — and, critically, only over the file header, cut at the class keyword, so that trait composition inside the class body is not reported:
$header = substr( $source, 0, $classDeclarationOffset );
preg_match_all( '/^\s*use\s+(?!function\s|const\s)([\w\\\\]+)(?:\s+as\s+(\w+))?\s*;/mi', $header, $uses, PREG_SET_ORDER );
foreach ( $uses as $use )
{
$imported = ltrim( $use[1], '\\' );
$name = $use[2] ?? substr( strrchr( '\\' . $imported, '\\' ), 1 );
if ( strtolower( $name ) === strtolower( $declaredClassName ) )
{
/* collision */
}
}
Run against a full core tree this reports zero — which is the point. The first version of it did not cut at the class keyword, reported core's XenForo forums converter, and would have been ignored from then on.
<style> tags reaching headCss. This one cannot be a file-level search, for the reason given in section 1. Find the methods whose return value is concatenated onto headCss…
preg_match_all( '/headCss\s*=.*?(?:static|self|\$this)\s*(?:::|->)\s*(\w+)\s*\(/s', $source, $m );
…then read just those method bodies (token_get_all(), counting braces, skipping comments — a regex will not survive a nested closure) and flag <style inside them. Handle the direct case too, where headCss is assigned a literal rather than a method call. Scoping it this way keeps pop-out windows and standalone documents, which need a real <style> tag, out of the results.
The pattern behind all six
Five of the six are a registration that was never made, or a guard doing its job too well:
- Not in
supportedClasses()→ the indexer returns early. - No
core_permission_indexrow → the node is filtered out of a subquery. - Not in a
Loaderextension → the dispatcher never reaches your code. isValidFile()still returningFALSE→ the sweep believes you.- An empty
catch→ the exception that would have told you is discarded.
None of those is a bug in core. Each is core correctly doing nothing with something you did not tell it about, and "correctly doing nothing" has no error message. The practical consequence is that reading the code proves less than you think: all six of these read correctly. What found them was looking at rendered output and counting rows in tables — asking what should be there, rather than what the code says it should do.
So the check that catches this class of bug is not a code review. It is a list of the observable things your application is supposed to produce — rows in these tables, this rule in the cascade, this item in search — verified one at a time against a running site.
Verified against
Invision Community 5.0.19, by reading source. Every file path and line number above was checked against that tree, and the two checks were run over the full applications/ directory. Line numbers are approximate and will drift between releases; the file paths and method names are the durable part.
One claim was checked and withdrawn: core's XenForo converter looks like an import collision and is not one. It is described in section 2 as the false positive it is, because a checker that reports it is a checker nobody will keep running.
Recommended Comments