The core/Uninstall extension is the only hook an application gets into its own removal, and the only way one application can react to a different application being removed. Core calls it from IPS\Application::delete() (system/Application/Application.php) — preUninstall() before any data is touched, onOtherUninstall() on every enabled application immediately after, and postUninstall() near the end, after your database tables have already been dropped.
Despite the docblock wording, there is no plugin system in IC5. There is no plugins/ directory and no IPS\Plugin class in 5.0.19; onOtherUninstall() only ever fires for applications.
The contract
system/Extensions/UninstallAbstract.php declares three methods. None of them are abstract — every one has an empty body, so you only override the ones you need. None return a value, and none declare a thrown exception.
namespace IPS\Extensions;
abstract class UninstallAbstract
{
/* Called on the app being removed, BEFORE core deletes anything.
$application is always your own app directory here. */
public function preUninstall( string $application ) : void {}
/* Called on the app being removed, AFTER core has dropped your
tables, deleted your settings, lang words, themes and widgets. */
public function postUninstall( string $application ) : void {}
/* Called on EVERY enabled application (including the one being
removed) when any application is uninstalled.
$application is the directory of the app going away. */
public function onOtherUninstall( string $application ) : void {}
}
The order core executes them in, taken from Application::delete():
| Step | What happens |
|---|---|
| 1 | $this->extensions( 'core', 'Uninstall' ) — your extension objects are constructed once and kept in an array |
| 2 | preUninstall( $this->directory ) on each of your extensions |
| 3 | onOtherUninstall( $this->directory ) on Application::allExtensions( 'core', 'Uninstall', FALSE ) — every enabled app |
| 4 | Profile steps, front navigation, club node maps, search index, then ~25 DELETEs from shared core_* tables |
| 5 | ContentRouter class cleanup, attachment maps, tasks, lang words, email templates, theme templates/CSS/resources |
| 6 | FileStorage extension delete(), upload settings, notification defaults, login handlers |
| 7 | Tables in data/schema.json are dropped; addColumn/addIndex from setup/install/queries.json are reverted |
| 8 | Widget rows, widget areas, widget trash |
| 9 | postUninstall( $this->directory ) on the same objects from step 1 |
| 10 | FURL definitions, parent::delete() (the core_applications row), data store flush, then the applications/<dir>/ folder is deleted from disk |
A minimal example
File: applications/myapp/extensions/core/Uninstall/Cleanup.php
<?php
namespace IPS\myapp\extensions\core\Uninstall;
use IPS\Data\Store;
use IPS\Db;
use IPS\Extensions\UninstallAbstract;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Cleanup extends UninstallAbstract
{
/**
* Before anything is removed - your tables still exist here.
*/
public function preUninstall( string $application ) : void
{
/* Rows we put in a core table that core will not match on app= */
if ( Db::i()->checkForTable( 'myapp_widget_map' ) )
{
Db::i()->delete( 'core_item_markers', array( 'item_app=?', 'myapp' ) );
}
}
/**
* After our tables are gone. Guard EVERY table access.
*/
public function postUninstall( string $application ) : void
{
/* Columns we added in an UPGRADE step are not reverted by core */
foreach ( array( 'myapp_score', 'myapp_last_seen' ) as $column )
{
if ( Db::i()->checkForColumn( 'core_members', $column ) )
{
Db::i()->dropColumn( 'core_members', $column );
}
}
/* Our own datastore keys */
if ( isset( Store::i()->myapp_config ) )
{
unset( Store::i()->myapp_config );
}
}
/**
* Someone else is being uninstalled. Never throw from here.
*/
public function onOtherUninstall( string $application ) : void
{
/* We are also called with our OWN directory - skip that case */
if ( $application === 'myapp' )
{
return;
}
try
{
if ( Db::i()->checkForTable( 'myapp_integrations' ) )
{
Db::i()->delete( 'myapp_integrations', array( 'int_app=?', $application ) );
}
}
catch ( \Exception $e )
{
/* Swallow: an exception here aborts the OTHER app's uninstall */
}
}
}
And the registration in applications/myapp/data/extensions.json:
{
"core": {
"Uninstall": {
"Cleanup": "IPS\\myapp\\extensions\\core\\Uninstall\\Cleanup"
}
}
}
My uninstall code never ran and the ACP said "Deleted"
The most likely cause is that the application was flagged app_requires_manual_intervention. Every suite upgrade sets this on all non-IPS applications — system/Dispatcher/Setup.php and applications/core/modules/setup/upgrade/applications.php both run Db::i()->update( 'core_applications', [ 'app_enabled' => 0, 'app_requires_manual_intervention' => 1 ], Db::i()->in( 'app_directory', IPS::$ipsApps, true ) ).
When the admin then deletes that application, applications/core/modules/admin/applications/applications.php::delete() checks the flag and reroutes to static::_deleteLegacyApp( $application['app_directory'] ). That method reimplements most of Application::delete() by hand and never calls preUninstall() or postUninstall() at all — it only calls onOtherUninstall() on the other apps. It also skips the search index entirely (the source literally reads // @todo Search index), skips your FileStorage extensions, skips your Notifications and LoginHandler cleanup, and does not run Store::i()->clearAll().
There is a second, related consequence: Application::applications() skips any row where app_requires_manual_intervention is set and the directory is not in IPS::$ipsApps. So a flagged third-party app is invisible to allExtensions() — it will not receive onOtherUninstall() when some other app is removed either.
There is no fix you can apply from inside the extension. Design for it: anything that absolutely must be cleaned up should also be safe to leave behind, and your install routine should be idempotent so a later reinstall tolerates leftovers.
The ACP reports "Deleted" but nothing was removed
This is the single most dangerous behaviour of this extension point. In the ACP controller, $node->delete() sits inside a try block whose first catch is:
/* Legacy */
catch ( UnexpectedValueException $e )
{
if( !Member::loggedIn()->hasAcpRestriction( 'core', 'applications', 'app_delete' ) )
{
Output::i()->error( 'node_noperm_delete', '2C133/J', 403, '' );
}
Db::i()->delete( 'core_applications', array( 'app_directory=?', Request::i()->id ) );
}
That catch exists to handle an application row with no loadable Application.php. But it wraps the entire delete() call, so if your preUninstall(), postUninstall() or onOtherUninstall() throws UnexpectedValueException — and a great deal of IPS code throws it, including Application::constructFromData() — core interprets it as "this is a legacy app", deletes the single core_applications row, and redirects with the deleted language string, which renders as "Deleted". Every table, setting, language word, theme template and file is left in place, and the app has no row so it can never be uninstalled properly again.
The error surfaces nowhere near its cause. There is no log entry, no error code, and no indication that your extension threw. Wrap every method body in try { … } catch ( \Exception $e ) {}, or at minimum guard against UnexpectedValueException:
public function preUninstall( string $application ) : void
{
try
{
$this->doCleanup();
}
catch ( \Exception $e )
{
/* Never let this escape - core would treat it as a legacy app */
}
}
"We could not locate the item you are trying to view." with code 2C133/I
Same try block, second catch: catch ( OutOfRangeException $e ) { Output::i()->error( 'node_error', '2C133/I', 404, '' ); }. OutOfRangeException is what ActiveRecord::load(), Application::load() and Settings lookups throw when a record is missing — exactly the sort of thing that happens in postUninstall(), where half your data is already gone.
The visible result is a 404 error page in the ACP with code 2C133/I, and the uninstall stops at whatever step you were on. If you threw from postUninstall(), the tables have already been dropped but core_applications still has the row, the FURL definitions are still registered and the app folder is still on disk — a half-uninstalled application that the ACP will still list. Catch OutOfRangeException anywhere you call ::load() in an Uninstall method.
Uninstalling someone else's app fails with an error from my code
Application::delete() loops the onOtherUninstall() calls with no error handling whatsoever:
foreach( static::allExtensions( 'core', 'Uninstall', FALSE ) as $extension )
{
$extension->onOtherUninstall( $this->directory );
}
Compare this with the FileStorage loop a few hundred lines later, which is wrapped in try { $extension->delete(); } catch( PHPException $e ){}. The Uninstall loop has no such protection.
An exception thrown from your onOtherUninstall() aborts the uninstall of an application you have nothing to do with, at step 3 of 10 — before core has deleted a single row. The admin sees an error while removing, say, Gallery, and there is nothing on the screen connecting it to your app. Your extension is only identifiable from the stack trace in core_error_logs. This is the classic "one bad app breaks a shared screen" case, and it is why the example above swallows exceptions in onOtherUninstall() unconditionally.
The same loop also means onOtherUninstall() runs on every enabled application on the site on every application uninstall. Keep it cheap and keep it defensive.
"Table 'x.myapp_something' doesn't exist" during postUninstall
By the time postUninstall() is called, core has already looped data/schema.json and run Db::i()->dropTable( $tableName, TRUE ) on every table in it. Your own tables are gone. Core's own Pages extension documents this with a bare comment at the top of the method:
public function postUninstall( string $application ) : void
{
/* cms_databases has been removed */
$tables = array();
…
}
Anything that needs to read your data must happen in preUninstall(). Anything in postUninstall() that touches a table must be guarded with Db::i()->checkForTable() — including tables you think still exist, because a partially-failed earlier uninstall attempt may have already removed them. Note that Databases::preUninstall() guards even the pre-hook with if ( Db::i()->checkForTable( 'cms_databases' ) ).
The reverse is also true and useful: at postUninstall() time the core_applications row still exists and applications/<dir>/ is still on disk, because both are removed afterwards. You can still read your own data/*.json files there.
onOtherUninstall fires with my own app's directory
allExtensions() iterates static::applications(), and at step 3 the application being removed is still enabled and still in core_applications — parent::delete() does not run until step 10. So your own onOtherUninstall() is invoked with your own directory as $application, in addition to the preUninstall() you already received.
Core's Pages extension relies on this (cms\extensions\core\Uninstall\Widgets has only an onOtherUninstall() and it is what cleans up Pages' own widget areas when Pages itself is removed). But if your onOtherUninstall() writes rows, re-caches, or rebuilds something on behalf of the surviving apps, it will do that work for an app that is about to cease to exist. Test $application against your own directory and return early if you do not want that.
A disabled app never gets a chance to clean up
allExtensions() contains two hard filters before it collects any extension:
if( RECOVERY_MODE and !in_array( $application->directory, IPS::$ipsApps ) )
{
continue;
}
if ( !static::appIsEnabled( $application->directory ) )
{
continue;
}
So onOtherUninstall() is skipped for every disabled application, and — if the site is in recovery mode — for every third-party application. Rows that a disabled app holds pointing at the removed app stay there forever, and there is no retry when the app is re-enabled.
Note the asymmetry: preUninstall()/postUninstall() come from $this->extensions( 'core', 'Uninstall' ), an instance method that checks neither appIsEnabled() nor recovery mode. Your own uninstall code therefore runs even when your app is disabled — but your own onOtherUninstall() does not, because that comes from the filtered static call.
My extension is never constructed and no error appears
Application::extensions() reads data/extensions.json and skips silently on two conditions:
foreach ( $json[ $app ][ $extension ] as $name => $classname )
{
if( !is_string( $classname ) or !class_exists( $classname ) )
{
/* Switching between branches confuses extensions */
continue;
}
…
}
and constructExtensionClass() returns null — again silently — if class_exists() fails or if the constructor throws RuntimeException or OutOfRangeException. Nothing is logged in any of those paths.
So a typo in the class name, a namespace that does not match the directory, a missing data/extensions.json entry, or a fatal-free parse problem all present identically: the uninstall completes cleanly and your cleanup simply did not happen. In dev mode the JSON is regenerated by Application::buildExtensionsJson(), which scans applications/<dir>/extensions/ and skips any class that does not exist or that defines a deprecated() method — visit the Developer Center once after adding the file, and verify the entry is actually in the built data/extensions.json you ship.
Fatal TypeError when the uninstall starts
UninstallAbstract declares no constructor, but core always instantiates extensions with one argument:
$obj = new $classToUse( $checkAccess === TRUE ? Member::loggedIn() : ( $checkAccess === FALSE ? NULL : $checkAccess ) );
For Uninstall extensions $checkAccess is FALSE in both call sites, so your class is constructed as new Cleanup( NULL ). If you copy a constructor signature from another extension type and write public function __construct( Member $member ), that is a TypeError. constructExtensionClass() only catches RuntimeException and OutOfRangeException, so a TypeError escapes it, escapes the ACP's two catches, and produces a hard error page. Either declare no constructor at all, or accept a nullable first argument: public function __construct( ?Member $member = NULL ).
Columns and rows my installer added are still in core tables
On uninstall core reverts setup/install/queries.json, but the switch only has two cases and no default:
foreach( $schema as $instruction )
{
switch ( $instruction['method'] )
{
case 'addColumn': /* dropColumn, ignoring error 1091 */ break;
case 'addIndex': /* dropIndex, ignoring error 1091 */ break;
}
}
Two consequences, both verifiable against IPS's own apps:
insertis never reverted.calendar,downloads,forumsandgalleryall use"method": "insert"in their install queries. Those rows survive the uninstall permanently.- Only
setup/install/queries.jsonis read. The per-versionsetup/upg_<version>/queries.jsonfiles are never consulted on delete. A column you added tocore_membersin an upgrade step, rather than at install, is not dropped.
Drop those yourself in postUninstall() with a Db::i()->checkForColumn() guard, as in the example above, and delete your own inserted rows in preUninstall().
One of my tables survived the uninstall
Table dropping is driven entirely by data/schema.json — core iterates its keys and drops each one, swallowing only MySQL error 1051 ("unknown table"). A table you created directly with an addTable instruction in an upgrade step, or with raw SQL, and never added to schema.json is invisible to that loop and will remain in the database after the app is gone.
Pages has exactly this problem and solves it in postUninstall() by pattern-matching the table names, which is the general fix:
try
{
$databaseTables = Db::i()->query( "SHOW TABLES LIKE '" . Db::i()->prefix . "cms_custom_database_%'" )->fetch_assoc();
…
}
catch( Exception $ex ) { }
foreach( $tables as $table )
{
if ( Db::i()->checkForTable( $table ) )
{
Db::i()->dropTable( $table );
}
}
Note that Pages defensively handles fetch_assoc() returning either a scalar or an array per row (if( is_array( $row ) ) { $tables[] = array_pop( $row ); } else { $tables[] = $row; }) — the shape of a SHOW TABLES LIKE result is not stable enough for core to rely on.
A Pages block still points at my widget after my app is uninstalled
cms\extensions\core\Uninstall\Widgets::onOtherUninstall() deletes cms_blocks rows whose block_plugin_app matches the removed app — but that loop is nested inside the loop over cms_page_widget_areas:
foreach ( Db::i()->select( '*', 'cms_page_widget_areas' ) as $row )
{
…
/* Remove blocks using a widget from the uninstalled application */
foreach( new ActiveRecordIterator( Db::i()->select( '*', 'cms_blocks', array( "block_plugin_app=?", $application ) ), Block::class ) AS $block )
{
$block->delete();
}
}
If cms_page_widget_areas is empty — a site with Pages installed but no widgets placed on any page — the inner loop never executes and your blocks are left behind. If it has many rows, the same delete is re-run once per row. This is core's code, not yours; you cannot fix it from your own extension, but you can clean up after it in your own preUninstall() by deleting from cms_blocks yourself, guarded with Db::i()->checkForTable( 'cms_blocks' ) since Pages may not be installed.
A property I set in preUninstall is missing in onOtherUninstall
Core constructs your extension twice. Step 1 builds the objects used for preUninstall() and postUninstall() and deliberately keeps them — the source comment reads "They are stored in an array so that we only create one object per extension, instead of one each time we loop". Step 3 calls allExtensions(), which constructs a completely separate set of objects for onOtherUninstall().
So state stored on $this in preUninstall() is visible in postUninstall() — that is a supported way to hand data across the destructive middle of the uninstall — but it is not visible in onOtherUninstall(), even though that method is on the same class and runs in between. Use a static property or a datastore key if you need it in all three.
What core cleans up for you, and what it leaves behind
Before writing any cleanup code, check this list — most of it is already done. Core deletes, keyed on your app directory: core_modules, core_permission_index, core_dev, core_item_markers, core_reputation_index, core_upgrade_history, core_admin_logs, core_sys_conf_settings (your settings), core_queue, core_follow, core_follow_count_cache, core_item_statistics_cache, core_view_updates, core_moderator_logs, core_member_history, core_acp_notifications, core_solved_index, core_notifications, core_javascript, core_theme_templates_custom, core_sys_lang_words, core_email_templates, core_tasks and core_tasks_log, plus core_achievements_rules whose action starts with <yourapp>_.
Keyed on classes returned by your ContentRouter extensions it also clears core_approval_queue, core_deletion_log, core_content_promote, core_ratings, core_item_redirect, core_item_member_map, core_rss_import, core_soft_delete_log, core_post_before_registering, core_anonymous_posts, core_polls/core_voters, core_assignments and the core_rc_* report tables. Profile steps, front navigation, club node maps, notification defaults and preferences, login handler rows, theme templates/CSS/resources/editor settings, widgets, widget areas, widget trash and FURL definitions are all handled too.
What it does not do:
- Delete attachment files. Only
core_attachments_maprows for your EditorLocations keys are removed; the comment says "if the attachment is unused, the regular cleanup task will remove the file later". - Guarantee your stored files are gone.
FileStorage::delete()is called insidetry { … } catch( PHPException $e ){}— any failure is swallowed with no log. - Remove custom language words saved under a different
word_app. Onlyword_app = <yourapp>is deleted, so node titles you saved withLang::saveCustom( 'core', … )survive. - Remove the search index for classes not declared by a
core/SearchContentextension.Index::i()->removeApplicationContent()iterates$application->extensions( 'core', 'SearchContent' )and callsremoveClassFromSearchIndex()for eachsupportedClasses()entry. Pages'preUninstall()exists purely to work around this and explains why: "The content router only returns databases linked to pages… so the method to remove all app content from the search index fails". - Remove your
IPS\Data\Storekeys, in the legacy delete path. The normal ACP path callsCache::i()->clearAll()andStore::i()->clearAll()afterdelete()returns;_deleteLegacyApp()only unsetsmodules,applications,widgetsandfurl_configuration. Pages unsetsStore::i()->cms_menuin its ownpostUninstall(); do the same for yours. - Delete your files on a developer install. The
applications/<dir>/removal at the end is wrapped inif ( !CIC2 AND !IN_DEV AND !DEMO_MODE AND file_exists( … ) ), and everyrmdir/unlinkis error-suppressed with@.
Verified against
Read from Invision Community 5.0.19 source: system/Extensions/UninstallAbstract.php, system/Application/Application.php (delete(), extensions(), allExtensions(), constructExtensionClass(), applications(), appIsEnabled(), buildExtensionsJson()), applications/core/modules/admin/applications/applications.php (delete(), _deleteLegacyApp()), system/Content/Search/Index.php and system/Content/Search/Mysql/Index.php, and the three shipped implementations: cms/extensions/core/Uninstall/Databases.php, cms/extensions/core/Uninstall/Widgets.php and nexus/extensions/core/Uninstall/DisablePOP.php (which is an empty postUninstall() stub and does nothing). No plugin system exists in this version; onOtherUninstall() fires for applications only.
Recommended Comments