# Module Migration Guide

Migration notes for keeping a module compatible with newer HumHub core releases.

Each minor release line has its own file with the breaking changes, new APIs and deprecations. Notes for the **current development cycle** live in the `Unreleased` section below — pull requests that introduce breaking changes should add an entry here, not in a release-specific file.

## Unreleased

- Added `humhub\modules\content\models\Content::EVENT_BEFORE_HARD_DELETE` (`ContentEvent`),
  triggered from `Content::hardDeleteInternal()` right before a `Content` record is physically
  removed. Modules that store rows referencing `content_id` with a restrictive (non-cascading)
  foreign key — like Comment and Like — must clean those rows up on this event, **in addition**
  to `ContentActiveRecord::EVENT_BEFORE_DELETE`. The latter only fires while deleting the
  polymorphic content object itself (e.g. a `Post`); when a `Content` record is hard-deleted
  directly because its polymorphic object no longer exists (e.g. by `IntegrityController`),
  `ContentActiveRecord::EVENT_BEFORE_DELETE` never fires and addon rows were left behind,
  causing foreign key constraint violations during `integrity/run`.
- Added the central **user gate** system for modules that intercept requests to route the user
  through a mandatory flow (2FA check, terms acceptance, first-use wizards) — see
  `docs/develop/user-gates.md`. Modules using `Controller::EVENT_BEFORE_ACTION` for this purpose
  should migrate to a `UserGate` registered via `GateManager::EVENT_INIT_GATES` and bump
  `humhub.minVersion` to `1.19`. Until migrated, legacy interceptors keep working but should
  guard against double interception with
  `if (!$event->isValid || Yii::$app->response->getIsRedirection()) { return; }`.
  - **Removed** (unused by any known module): `ControllerAccess::RULE_MUST_CHANGE_PASSWORD` and
    `ControllerAccess::RULE_MAINTENANCE_MODE` (incl. their validators
    `validateMustChangePassword()` / `validateMaintenanceMode()`),
    `AccessControl::forceChangePassword()` / `checkMaintenanceMode()`, and the `codeCallback`
    mechanism on `ControllerAccess`/`DelegateAccessValidator`/`AccessControl`. A custom
    `ControllerAccess`/validator that still declares and sets a `codeCallback` now fails with an
    `InvalidConfigException` instead of the callback being silently ignored. Enforcement moved to
    `humhub\modules\user\components\MustChangePasswordGate` and
    `humhub\modules\user\components\MaintenanceModeGate`; the forced logout of non-admins
    during maintenance now happens in the gate's `onIntercept()` hook.
  - Requests classified as AJAX/PJAX now receive `401` + JSON `{gate, url}` (plus an
    `X-Redirect` header handled by `yii.js`) instead of a `302` to an HTML page when a gate
    intercepts; token-authenticated API requests (server-side, `enableSession = false`)
    receive `403` + JSON.
  - **Removed** `Controller::$doNotInterceptActionIds` and `Controller::isNotInterceptedAction()`
    (`@since 1.9`, unused by the gate system and uncalled by any known module). The flag was a
    controller's way to opt individual actions out of interception; that is now handled by the
    gates themselves (`UserGateInterface::appliesTo()` / `getAllowedRoutes()`), and stateless
    API endpoints are exempt via the request classification. A module controller that still
    declares the property keeps working (it becomes an unused own property) but the declaration
    can be removed.
- `humhub\modules\content\components\ActiveQueryContent::readable()` and `::userRelated()` no
  longer accept a `$user` parameter. The user is now resolved once, in the constructor — either
  the current session user (`Yii::$app->user->getIdentity()`) or an explicit user passed as the
  second constructor argument: `new ActiveQueryContent($modelClass, $user)`. Modules calling
  `->readable($user)` or `->userRelated($scopes, $user)` must switch to constructing the query
  with that user instead.
  **Warning:** PHP silently ignores the extra argument, so an unmigrated call does not fail —
  it evaluates access for the **session** user instead of the passed one: over-broad results
  for admins, empty results in console/queue jobs. Audit every `readable(...)`/`userRelated(...)`
  call that passes a user.
- Icon-only `Button` widgets (no visible label, only an icon) now automatically set `aria-label` from
  the tooltip text. Module developers should always call `->tooltip('...')` on icon-only buttons —
  omitting it logs a `Yii::warning()` in `YII_DEBUG` mode.
  Explicit `aria-label` via `->options(['aria-label' => '...'])` always takes precedence.
- Split rich text short converters by output safety
  - Added `humhub\modules\content\widgets\richtext\converter\RichTextToShortHtmlConverter`
    producing HTML encoded short previews (with the `nl2br` option). Use this whenever
    the result will be rendered inside an HTML view.
  - `humhub\modules\content\widgets\richtext\converter\RichTextToShortTextConverter`
    now returns **unencoded** plain text and no longer supports the `nl2br` option.
    Use this for plain text contexts such as mail subjects.
    **Modules rendering its result in HTML views must switch to
    `RichTextToShortHtmlConverter` or wrap the output with `Html::encode()`** to
    avoid XSS. Direct callers of `convertToShortText()` are affected in the same
    way — use the new `convertToShortHtml()` for HTML rendering.
  - Added `convertToShortHtml()` on `AbstractRichTextConverter` and corresponding
    `RichText::FORMAT_SHORT_HTML` / `RichText::FORMAT_SHORT_TEXT` format constants.
    `RichText::FORMAT_SHORTTEXT` is deprecated and aliased to `FORMAT_SHORT_HTML`
    for backward compatibility. `RichText::preview()` internally uses `FORMAT_SHORT_HTML`.
  - `SocialActivity::getContentPreview()` / `ContentHelper::getContentPreview()` now
    use `RichTextToShortHtmlConverter` to keep their previous (HTML encoded) output.
  - Mail subjects are now decoded centrally in
    `humhub\modules\notification\targets\MailTarget::handle()` and in
    `BaseNotification::asArray()`. Per-notification `Html::decode()` workarounds in
    `getMailSubject()` overrides should be removed.
- Refactored `ContentAddons`
  - Improved `humhub\modules\content\components\ContentAddonActiveRecord`, now required `content_id` attribute
    - Removed `user` relation, use `createdBy` instead.
  - Removed `humhub\modules\content\components\ContentAddonController`
  - Introduced `ContentProvider` interface (May change!)
- Added `RecordMap` to improve polymorphic models relations
- Refactored `comment` module
  - Replaced Polymorphic Relations with `comment.content_id` and `comment.parent_comment_id`
  - Introduced `CommentListService`
  - Removed `CommentForm`
  - `Comment::getUrl()` default for `$scheme` changed from `true` to `false` (aligned with `ContentAddonActiveRecord::getUrl()`) — pass `getUrl(true)` where an absolute URL is required
- Refactored `like` module
  - Introduced `LikeService` and added `like.content_id`
  - Used `RecordMap` for ContentAddon relations
- Removed methods `getContentPlainTextInfo()` and `getContentPlainTextPreview()` from the class `SocialActivity`(`BaseNotification`)
  - Replace them with `getContentInfo()` and `getContentPreview()` in all extended classes, especially inside the method `getMailSubject()`
- Replaced classes:
  - `humhub\widgets\BaseMenu` => `humhub\modules\ui\menu\widgets\Menu`
  - `humhub\widgets\Button` => `humhub\widgets\bootstrap\Button`
  - `humhub\widgets\Label` => `humhub\widgets\bootstrap\Badge`
  - `humhub\widgets\ContentTagDropDown` => `humhub\modules\content\widgets\ContentTagDropDown`
  - `humhub\widgets\DatePicker` => `humhub\modules\ui\form\widgets\DatePicker`
  - `humhub\widgets\DurationPicker` => `humhub\modules\ui\form\widgets\DurationPicker`
  - `humhub\widgets\GlobalConfirmModal` => `humhub\widgets\modal\GlobalConfirmModal`
  - `humhub\widgets\GlobalModal` => `humhub\widgets\modal\GlobalModal`
  - `humhub\widgets\Link` => `humhub\widgets\bootstrap\Link`
  - `humhub\widgets\LinkPager` => `humhub\widgets\bootstrap\LinkPager`
  - `humhub\widgets\Modal` => `humhub\widgets\modal\Modal`
  - `humhub\widgets\ModalButton` => `humhub\widgets\modal\ModalButton`
  - `humhub\widgets\ModalClose` => `humhub\widgets\modal\ModalClose`
  - `humhub\widgets\ModalDialog` => `humhub\widgets\modal\Modal`
  - `humhub\widgets\Tabs` => `humhub\widgets\bootstrap\Tabs`
  - `humhub\widgets\TimePicker` => `humhub\modules\ui\form\widgets\TimePicker`
  - `humhub\modules\search\interfaces\Searchable` => `humhub\modules\content\interfaces\Searchable`
  - `humhub\components\queue` => `humhub\modules\queue\ActiveJob`
  - `humhub\libs\Html` => `humhub\helpers\Html`
  - `humhub\libs\ThemeHelper` => `humhub\helpers\ThemeHelper`
  - `humhub\modules\activity\widgets\Stream` => `humhub\modules\activity\widgets\ActivityStreamViewer`
  - `humhub\modules\content\components\actions\ContentContainerStream` => `humhub\modules\stream\actions\ContentContainerStream`
  - `humhub\modules\content\widgets\WallEntry` => `humhub\modules\content\widgets\stream\WallStreamEntryWidget`
  - `humhub\modules\space\modules\manage\widgets\Menu` => `humhub\modules\space\widgets\HeaderControlsMenu`
  - `humhub\modules\topic\widgets\TopicLabel` => `humhub\modules\topic\widgets\TopicBadge`
  - `humhub\modules\ui\form\widgets\ActiveField` => `humhub\widgets\form\ActiveField`
  - `humhub\modules\ui\form\widgets\ActiveForm` => `humhub\widgets\form\ActiveForm`
  - `humhub\modules\ui\form\widgets\ContentHiddenCheckbox` => `humhub\widgets\form\ContentHiddenCheckbox`
  - `humhub\modules\ui\form\widgets\ContentVisibilitySelect` => `humhub\widgets\form\ContentVisibilitySelect`
  - `humhub\modules\ui\form\widgets\FormTabs` => `humhub\widgets\bootstrap\FormTabs`
  - `humhub\modules\ui\form\widgets\SortOrderField` => `humhub\widgets\form\SortOrderField`
  - `humhub\modules\ui\mail\DefaultMailStyle` => `humhub\helpers\MailStyleHelper`
  - `humhub\modules\ui\view\components\View` => `humhub\components\View`
  - `humhub\modules\ui\view\helpers` => `humhub\helpers\ThemeHelper`
  - `humhub\modules\user\components\ProfileStream` => `humhub\modules\user\actions\ProfileStreamAction`
  - `humhub\modules\user\widgets\UserPicker` => `humhub\modules\user\widgets\UserPickerField` for rendering, `humhub\modules\user\models\UserPicker` for searching
- Removed classes:
  - `humhub\widgets\BootstrapComponent`
  - `humhub\assets\Select2BootstrapAsset`
  - `humhub\modules\search\events\SearchAddEvent`
  - `humhub\libs\DynamicConfig`
  - `humhub\modules\content\widgets\LegacyWallEntryControlLink`
  - `humhub\modules\content\widgets\Stream`
  - `humhub\modules\directory\Module`
  - `humhub\modules\file\widgets\FileUploadButton`
  - `humhub\modules\file\widgets\FileUploadList`
  - `humhub\modules\queue\driver\MySQLCommand`
  - `humhub\modules\user\authclient\AuthClientHelpers`
  - `humhub\modules\user\authclient\Facebook`
  - `humhub\modules\user\authclient\GitHub`
  - `humhub\modules\user\authclient\Google`
  - `humhub\modules\user\authclient\LinkedIn`
  - `humhub\modules\user\authclient\Live`
  - `humhub\modules\user\authclient\Twitter`
  - `humhub\modules\user\authclient\ZendLdapClient`
- Replaced methods:
  - `humhub\widgets\bootstrap\Link::asLink()` => `humhub\widgets\bootstrap\Link::to()`
  - `humhub\widgets\bootstrap\Button::asLink()` => `humhub\widgets\bootstrap\Link::to()`
  - `humhub\widgets\bootstrap\ModalButton::asLink()` => `humhub\widgets\bootstrap\Link::modal()`(new since v1.19)
  - `humhub\modules\ui\menu\widgets\Menu->addItem([...])` => `humhub\modules\ui\menu\widgets\Menu->addEntry(new MenuLink([...]))`(used in module files `Events.php` as `$event->sender->addItem([...])`)
  - `humhub\widgets\bootstrap\Link::userPickerSelfSelect()` => `humhub\modules\user\widgets\UserPickerField::selfSelect()` or use new option `UserPickerField->selfSelect`
  - `humhub\modules\content\models\Content->delete()` => `humhub\modules\content\models\Content->getPolymorphicRelation()->delete()`
  - `humhub\modules\content\models\Content->hardDelete()` => `humhub\modules\content\models\Content->getPolymorphicRelation()->hardDelete()`
  - `humhub\modules\notification\components\BaseNotification->getAsText()` => `text()` — the shims were deprecated since 1.2 and are now removed
  - `humhub\modules\notification\components\BaseNotification->getAsHtml()` => `html()`
  - `humhub\modules\user\models\User->canViewAllContent()` => `humhub\modules\user\models\User->canManageAllContent()` — now permission-based: requires the new `ManageAllContent` permission and the admin module setting `enableManageAllContentPermission`; the semantics changed from viewing to managing all content
  - `humhub\modules\user\models\User->canApproveUsers()` => `humhub\modules\user\models\User->canManageUsers()`
  - `humhub\modules\space\models\Space->isModuleEnabled($id)` => `$container->moduleManager->isEnabled($id)` — was deprecated; the module manager is available on any `ContentContainerActiveRecord`
- Refactored `Activities`
  - Make sure Content related Activities are now extended from `BaseContentActivity`
  - `getTitle` and `getDescription` are now `static`.
  - Instead of View files you need to implement a `getMessage()` method which returns the Activity text.
  - Use following code to create a Activity `ActivityManager::dispatch(TaskCompletedActivity::class, $this->task, $user)`
  - Database: the `activity` table's polymorphic `object_model`/`object_id` columns were replaced by `contentcontainer_id`, `content_id` and `content_addon_record_id` foreign keys — modules querying the table directly must migrate.
  - **Upgrade note:** the activity restructure migration (`m260108_115053_new_structure`) is irreversible (no `down()`), rewrites the whole table with multi-table UPDATEs and permanently deletes activities that cannot be resolved to existing content (logged). Take a database backup before upgrading and plan a maintenance window on installations with a large `activity` table.
- Introduced **UserSource architecture** — separates user provisioning (who owns the user) from authentication (how the user logs in)
  - New `humhub\modules\user\source\UserSourceInterface` — contract for user provisioning sources
  - New `humhub\modules\user\source\BaseUserSource` — abstract base with sensible defaults; provides a default `updateUser()` that applies attributes listed in `$managedAttributes`
  - New `humhub\modules\user\source\LocalUserSource` — handles self-registered / admin-created users; default fallback for any AuthClient not claimed by another UserSource
  - New `humhub\modules\user\source\GenericUserSource` — fully config-driven source for custom integrations
  - New `humhub\modules\user\source\UserSourceCollection` — application component (`Yii::$app->userSourceCollection`); exposes `findUserSourceForAuthClient(string $clientId)` for AuthClient → UserSource dispatch
  - New `humhub\modules\user\services\UserSourceService` — per-user capability checks and lifecycle helpers
    - `UserSourceService::getForUser(?User $user = null)` — factory method; falls back to current identity
    - `UserSourceService::updateUser(array $attributes)` — updates user via UserSource and fires lifecycle event
    - `UserSourceService::deleteUser()` — removes user via UserSource and fires lifecycle event
    - `UserSourceService::triggerAfterCreate(User $user)` — fires lifecycle event after creation
  - New `humhub\modules\ldap\source\LdapUserSource` — extracted from `LdapAuth`; handles LDAP user lifecycle
  - Database: `user.auth_mode` + `user.authclient_id` replaced by `user.user_source` (string source ID)
  - Database: LDAP identity now stored in `user_auth` table (`source='ldap'`, `source_id=<idAttribute value>`) — consistent with OAuth
  - **Upgrade note — external auth identities:** the migration only moves `auth_mode = 'ldap'` into `user_auth`. For every other value (e.g. `saml`, `jwt`, spd-login) the `auth_mode`/`authclient_id` columns are dropped **without** a data move — affected users end up as `user_source = 'local'` and lose their external identity mapping. Until the SSO modules ship their own pre-drop migrations, update those modules first (once available) or back up the `user.auth_mode` / `user.authclient_id` columns before upgrading so the identities can be restored into `user_auth` afterwards.
  - New class-level lifecycle events on `UserSourceService` — listen via `Event::on(UserSourceService::class, ...)`:
    - `UserSourceService::EVENT_AFTER_CREATE` (`'afterUserSourceCreate'`) — fired after a UserSource creates a user
    - `UserSourceService::EVENT_AFTER_UPDATE` (`'afterUserSourceUpdate'`) — fired after a UserSource updates a user
    - `UserSourceService::EVENT_AFTER_DELETE` (`'afterUserSourceDelete'`) — fired after a UserSource removes a user
  - **AuthClient → UserSource link is declarative on the UserSource side** — AuthClients stay vanilla `\yii\authclient\*` implementations with no HumHub-specific interfaces. Each UserSource declares which AuthClient IDs it is responsible for via `$allowedAuthClientIds`; that list governs login authorisation, sync, and createUser dispatch.
  - LDAP: `LdapUserSource::$allowedAuthClientIds` (default `['ldap']`) — restricts which auth clients LDAP users may use; configurable in admin UI
  - LDAP: login with a disallowed auth client is now blocked in `AuthController` with an error flash
- Removed `humhub\modules\user\authclient\BaseClient`
  - Replace `extends BaseClient` with `extends \yii\authclient\BaseClient` (or `BaseFormClient` for form-based clients)
  - `BaseClient::EVENT_CREATE_USER` removed — replace listeners with `Event::on(UserSourceService::class, UserSourceService::EVENT_AFTER_CREATE, $handler)`
  - `BaseClient::EVENT_UPDATE_USER` removed — replace listeners with `Event::on(UserSourceService::class, UserSourceService::EVENT_AFTER_UPDATE, $handler)`
  - `BaseClient::canBypassApproval()` removed — configure on the UserSource via `$approval` / `$trustedAuthClientIds` (see below)
  - `BaseClient::beforeSerialize()` removed — implement `humhub\modules\user\authclient\interfaces\SerializableAuthClient` instead
- Removed `humhub\modules\user\authclient\interfaces\SyncAttributes` (was deprecated since 1.16; the legacy sync path in `AuthClientService` is removed)
  - Replacement: have a UserSource declare which AuthClient IDs it accepts via `$allowedAuthClientIds`. The UserSource's `updateUser()` (or the default in `BaseUserSource` driven by `$managedAttributes`) writes the synced fields.
  - Affected modules requiring migration: `saml-sso`, `jwt-sso`, `spd-login`, and any custom AuthClient that implemented `SyncAttributes`. Existing local users authenticating via SAML/JWT will no longer be sync'd unless an admin opts in by adding the auth client ID to `LocalUserSource::$allowedAuthClientIds` and listing the synced fields in `LocalUserSource::$managedAttributes`.
- Removed `humhub\modules\user\authclient\interfaces\SerializableAuthClient` and its `beforeSerialize()` hook. AuthClient instances are no longer stored in the session at all — `AuthController` now hands the auth state to the registration form via `humhub\modules\user\services\PendingAuthService`, which captures only the client id + already-normalised user attributes. The AuthClient is reconstructed from the AuthClientCollection on the receiving side. Closures in normalize maps, connection handles, and other non-serialisable client state are no longer a session concern. Modules with custom AuthClients can drop `implements SerializableAuthClient` and their `beforeSerialize()` method.
- Deprecated `humhub\modules\user\authclient\interfaces\ApprovalBypass` — approval is now configured on the UserSource side. AuthClients stay vanilla `\yii\authclient\*` implementations.
  - The interface is kept as an empty marker so modules still implementing it don't fatal-error, but core no longer reads it. `RegistrationController` and `AuthClientService::allowSelfRegistration()` no longer fall back on `instanceof ApprovalBypass`.
  - Migration: drop `implements ApprovalBypass` from your AuthClient. To skip approval for users provisioned via that auth client, configure the responsible UserSource with `'approval' => true, 'trustedAuthClientIds' => ['<client-id>']` — or leave `$approval = false` (default) to skip approval entirely for that source.
  - `UserSourceInterface::requiresApproval(?string $authClientId = null)` decides per-request: form-based self-registration passes `null`; auth-client-driven registration passes the client ID.
  - Core `LdapAuth` no longer implements `ApprovalBypass`; the LDAP approval policy is owned by `LdapUserSource`.
- Deprecated `humhub\modules\user\authclient\interfaces\PrimaryClient` is no longer read by core — `AccountController::actionConnectedAccounts()` and `AccountSettingsMenu::getSecondaryAuthProviders()` filter purely on `BaseFormClient` now (source-owning clients all extend it). `Password` no longer implements `PrimaryClient`. Interface kept as empty marker for backwards compatibility.
- Deprecated `humhub\modules\user\models\User::STATUS_DISABLED` — renamed to `STATUS_DEACTIVATED` for clarity (the constant describes an admin-deactivated account, not a disability). The deprecated alias points to the same value (`0`), so persisted statuses and DB queries are unaffected; modules should switch references to `User::STATUS_DEACTIVATED`. The associated i18n key in `AdminModule.user` was also renamed `'Disabled'` → `'Deactivated'` (and `'Disabled users'` → `'Deactivated users'`); existing language files have been migrated.
- Removed `humhub\modules\user\authclient\interfaces\StandaloneAuthClient` and its dispatcher fallback. Migrate to the new `humhub\modules\user\authclient\interfaces\CustomAuth` interface:
  ```php
  // Before:
  use humhub\modules\user\authclient\BaseClient;
  use humhub\modules\user\authclient\interfaces\StandaloneAuthClient;

  class MyClient extends BaseClient implements StandaloneAuthClient {
      public function authAction($authAction) {
          // custom logic
          return $authAction->authSuccess($this);
      }
  }

  // After:
  use humhub\modules\user\authclient\interfaces\CustomAuth;
  use yii\authclient\BaseClient;
  use yii\web\Response;

  class MyClient extends BaseClient implements CustomAuth {
      public function handleAuthRequest(): ?Response {
          // custom logic. Return a Response for a redirect (e.g. SP → IdP),
          // or null to signal completion — AuthAction calls authSuccess()
          // automatically.
      }
  }
  ```
  `humhub\modules\user\authclient\AuthAction` no longer falls back on the legacy marker — custom auth clients that didn't migrate will throw a `NotSupportedException` at dispatch time.
- Renamed `humhub\modules\user\authclient\BaseFormAuth` to `BaseFormClient`. The previous name doubled the "Auth" suffix with the surrounding `authclient/` namespace — the new name mirrors Yii's `BaseClient` parent. Drop-in rename; the class lives in the same namespace.
- Added `humhub\modules\user\authclient\interfaces\SingleLogout` — capability marker for AuthClients that support Single Logout (terminating the user's session at the identity provider, not just locally). `AuthController::actionLogout()` calls `$client->singleLogout(): ?Response` on the user's current AuthClient before tearing down the local session; a returned Response (typically a redirect SP → IdP) short-circuits, the IdP eventually redirects back to a module-owned callback URL that finalises the local logout. Modules previously implementing SLO via `EVENT_BEFORE_ACTION` interception on `AuthController` (saml-sso) should migrate to the interface and drop the event hook.
- Added `humhub\modules\user\authclient\interfaces\PasswordAuth` — declares the contract for AuthClients that authenticate via the login form (password-based):
  ```php
  interface PasswordAuth {
      public function authenticate(string $username, string $password): bool;
  }
  ```
  `BaseFormClient` and its subclasses (Password, LdapAuth) now implement it. The old stateful pattern — set `$client->login = $loginForm`, then call `$client->auth()` — is replaced by explicit parameter passing. Custom form-auth modules need to rename `auth()` → `authenticate(string, string): bool` and read credentials from the parameters instead of `$this->login->...`. Implementations must still call `setUserAttributes()` on success so the downstream lookup in `AuthClientService::getUser()` works; the `User` lookup itself no longer happens inside `authenticate()`.
- `humhub\modules\user\authclient\AuthAction` now dispatches `CustomAuth` clients before falling through to the OAuth/OpenID families. `AuthController::actions()['external']` uses the HumHub AuthAction class again.
  - The `rememberMe` query-parameter handling (writing to `loginRememberMe` session key) is removed; remember-me for OAuth/SSO clients was never supported anyway
- Removed `humhub\modules\user\jobs\SyncUsers` — was deprecated since 1.16; register a dedicated sync job in your module instead (see `humhub\modules\ldap\jobs\LdapSyncJob` as example)
  - `humhub\modules\user\authclient\interfaces\AutoSyncUsers` is kept for now but no longer called by core — implement a dedicated queue job instead
- Removed from `humhub\modules\user\services\AuthClientService`:
  - `createRegistration()` — use `createUser()` instead
  - `legacySyncAttributes()` — implement `UserSourceInterface::updateUser()` on your UserSource instead
- Removed from `humhub\modules\user\services\AuthClientUserService`:
  - `getPrimaryAuthClient()` — use `UserSourceService::getForUser($user)->getUserSource()` instead
  - `canChangePassword()`, `canChangeEmail()`, `canChangeUsername()`, `canDeleteAccount()`, `getSyncAttributes()` — use `UserSourceService` directly
- Removed from `humhub\modules\user\models\User`:
  - `getUserSourceService()` — use `UserSourceService::getForUser($user)` instead
- Refactored `ldap` module: replaced `laminas/laminas-ldap` with `directorytree/ldaprecord`
  - Removed class `humhub\modules\ldap\components\ZendLdap`
  - Removed from `humhub\modules\ldap\authclient\LdapAuth`:
    - Property `$loginFilter`
    - Methods `getLdap()`, `setLdap()`, `getUserNode()`, `getUserDn()`, `getUserCollection()`, `getAuthClientInstance()`
  - Added `humhub\modules\ldap\services\LdapService` as the new LDAP connection and query layer
  - Added `LdapAuth::getLdapService()` returning `LdapService`
  - `cleanLdapResponse()` moved from `LdapService` to `humhub\modules\ldap\helpers\LdapHelper`
- Refactored `ldap` module to the connection-registry pattern (reference implementation of the UserSource architecture):
  - New `humhub\modules\ldap\connection\LdapConnectionConfig` — plain value object holding hostname, port, baseDn, bindDn, attribute mappings, etc.
  - New `humhub\modules\ldap\connection\LdapConnectionRegistry` — keyed registry of connections; instantiated lazily on `Module::getConnectionRegistry()`. The default `'ldap'` connection is populated from the DB-backed `LdapSettings` UI; additional read-only connections can be added via `protected/config/common.php` → `modules.ldap.connections.<id> = [...]`.
  - `humhub\modules\ldap\authclient\LdapAuth` is now a vanilla `BaseFormClient` that references its connection by ID (`$connectionId`). Connection parameters (hostname/port/baseDn/bindUsername/bindPassword/userFilter/idAttribute/usernameAttribute/emailAttribute/languageAttribute/ignoredDNs/networkTimeout/disableCertificateChecking/autoRefreshUsers/syncUserTableAttributes) are gone — read them from `LdapConnectionConfig` via `LdapAuth::getConfig()` if needed.
  - `LdapAuth` no longer implements `SerializableAuthClient` — it carries no connection state.
  - `LdapUserSource` is registered once per connection. The constructor takes `$connectionId` (not an `LdapAuth` instance). Sync uses the registered AuthClient for attribute normalisation but doesn't own it.
  - `LdapService` constructor now takes `LdapConnectionConfig` (was `LdapAuth`). Obtain instances via `Module::getConnectionRegistry()->getService($connectionId)`. The static `LdapService::create()` factory and `LdapService::getAuthClients()` are removed — use `LdapConnectionRegistry::getService($id)` and `LdapService::getAllUserEntries()` respectively.
  - `LdapSettings::getLdapAuthDefinition()` and `getLdapUserSourceDefinition()` removed — use `LdapSettings::getConnectionConfig()` which returns an `LdapConnectionConfig`.
  - `LdapAuth::$connectionId` and `LdapUserSource::$connectionId` are now required (no default `'ldap'` fallback) — instantiating either class without a connection ID throws `InvalidConfigException`. The bootstrap registers them per connection ID from the registry.
  - The LDAP UserSource is now registered via its own event hook on `UserSourceCollection::EVENT_BEFORE_USER_SOURCES_SET` (`Events::onUserSourceCollectionSet`), separate from the AuthClient registration on `Collection::EVENT_BEFORE_CLIENTS_SET`. The two collections are no longer coupled through a single event handler.
- `MigrateController::$includeModuleMigrations` is now `true` by default
- **Module migrations run with the module registered** — `migrate/up` fully registers each enabled module before applying its migrations (namespace alias from `config.php`, module instance via `Yii::$app->getModule('<id>')`), matching the web-based migration. A module that fails to register (e.g. still referencing removed core classes during an upgrade) is skipped with a warning; its migrations run when the module itself is updated. See [Migrations and module context](concept-models.md#migrations-and-module-context).
- SiteIcon: Remove support for manually uploaded `@web/uploads/icon/` icons
- New `AssetImage` class
  - `LogoImage`, `SiteIcon`, `LoginBackground`, `MailHeader`, `ProfileImage`, `ProfileBannerImage` are now deprecated or removed.
    - `Space|User::getProfileImage()` => `Space|User::getImage()` / `$container->image` (returns `AssetImage`); `getProfileImage()` itself remains as a deprecated shim returning `humhub\libs\ProfileImage`
    - `Space|User::getProfileBannerImage()` => `Space|User::getBannerImage()` / `$container->bannerImage` — **removed without a shim**: `getProfileBannerImage()`, the `profileBannerImage` property and the `humhub\libs\ProfileBannerImage` class no longer exist; modules using them fatal until migrated
    - `SiteLogo|SiteIcon|LoginBackground|MailHeader::getUrl()` => `Yii::$app->img->logo|icon|loginBackground|mailHeader->getUrl()`
- `AssetManager::forcePublish()` removed
- Removed `@filestore` Alias
- Removed `AssetManager::$preventDefer` option
- Removed the webroot `static/` asset tree — core static resources moved to `protected/humhub/resources` and are published through the asset manager (#8102). Core themes moved from the webroot `themes/` directory to `protected/humhub/themes`; custom themes in `@webroot/themes` keep working, the `@themes` alias is unchanged.
  - `Theme::getBasePath()` keeps pointing at the theme *source* directory — `@humhub/themes` for the core theme, `<module>/themes` for module provided themes, `@themes` (webroot) for custom themes. `views/` and `scss/` are built from there and are excluded from publishing, so anything served to the browser is only reachable below the new `Theme::getPublishedBasePath()`, the published counterpart of `getBasePath()` (alongside the existing `Theme::getPublishedResourcesPath()`).
    - `getPublishedBasePath()` and `getPublishedResourcesPath()` return a path **relative to the assets mount**, to be used with `Yii::$app->assetManager` or `Yii::$app->fs->getAssetsMount()` — not with native filesystem functions such as `is_file()` or `file_get_contents()`, since the assets mount may be remote (e.g. S3). Use `AssetManager::fileExists()`, `fileWrite()` and the new `AssetManager::fileLastModified()` (the counterpart of `filemtime()`) instead. `getPublishedResourcesPath()` previously returned an absolute path or a relative one depending on whether the theme had already been published in the same request.
    - `Theme::getPath()` is **not** overridden and still resolves against the theme source directory, per its Yii2 contract of returning an absolute local filesystem path — which published assets cannot satisfy on a remote mount. Use `getPublishedBasePath()` with the asset manager for published files, or `Theme::getUrl()` for a browser-facing URL.
    - Theme resources are **not** inherited from parent themes: `publishResources()` publishes only the theme's own directory and `getBaseUrl()`/`getPublishedBasePath()` only ever point at it, so a child theme has to ship every resource it references (unlike `views/`, which do fall back through the theme tree).
  - Removed the aliases `@web-static` / `@webroot-static` and the `humhub\components\assets\WebStaticAssetBundle` class. Asset bundles configuring `basePath = '@webroot-static'` / `baseUrl = '@web-static'` must switch to publishing via `$sourcePath` instead (e.g. `'@humhub/resources'` like the core bundles, or the module's own `resources/` directory).
- New Flysystem Filesystem Wrapper - Migrate all file access for assets and uploads to the Flysystem wrapper (`Yii::$app->fs->getDataMount()` or `Yii::$app->fs->getAssetsMount()`). Read more: https://flysystem.thephpleague.com/docs/usage/filesystem-api/
  - **`StorageManager::get()` (`$file->store->get()`) now returns a path relative to the data mount, no longer an absolute local filesystem path.** The same applies to `FileHistory::getFileStorePath()`. Any code treating the return value as a local path — `is_file()`, `file_exists()`, `file_get_contents()`, `filesize()`, `hash_file()`, `fopen()`, `Imagine::open()`, `Response::sendFile()`/`xSendFile()`, archive/scanner APIs — silently breaks (checks return `false`, reads fail) and must be migrated:
    - Read/write through the store API: `$file->store->getContent()`, `getContentStream()`, `setContent()`, `has()`, `fileSize()`, `mimeType()` — or use the Flysystem instance `$file->store->fs` directly. These work with any mount (local or remote/S3).
    - Image processing: `Image::getImagine()->load($file->store->getContent())` instead of `open()`; persist with `$file->store->setContent($image->get($format, $options))` instead of `$image->save()` (see `humhub\modules\file\libs\ImageHelper`).
    - `exif_read_data()` accepts a stream: pass `$file->store->getContentStream()`.
    - Only when serving a file via a web-server mechanism that requires a real local path (e.g. X-Sendfile): verify `Yii::$app->fs->getDataMountConfig() instanceof LocalMountConfig` and prefix `Yii::getAlias($dataMountConfig->path)` (see `humhub\modules\file\actions\DownloadAction`); otherwise fall back to `sendStreamAsFile()`.
  - `File::setStoredFile()` with a string argument now expects a **data-mount-relative** path (it is read via Flysystem). Passing an absolute local path (e.g. a temp file) now throws `Invalid parameter type.` — use `setStoredFileContent(file_get_contents($localPath))` or an `UploadedFile` instead.
  - `humhub\modules\file\components\StorageManagerInterface` was extended and strictly typed: new required methods `getContent()`, `getContentStream()`, `checksum()` and `mimeType()`, plus parameter/return types on all existing methods. A custom storage manager (configured via the file module's `$storageManagerClass`) fails on load until it implements the new methods with matching signatures.
- Added `humhub\modules\content\components\ContentContainerActiveRecord::EVENT_INIT_PROFILE_IMAGE`
  and `EVENT_INIT_BANNER_IMAGE` (`humhub\modules\content\events\ContentContainerImageEvent`) to customize
  or replace a container's profile/banner `AssetImage`. Use these instead of overriding `$profileImageClass`,
  which only affects the deprecated `ProfileImage` path.
- **Impersonation no longer grants access to private content.** Until now an admin impersonating a user saw
  everything that user sees. Private content and private spaces are now hidden while impersonating, and each
  impersonation is written to the log (category `user`). Both are configurable via the new
  `humhub\modules\user\components\Impersonation` component (`Yii::$app->user->impersonation`):

  ```php
  'components' => [
      'user' => [
          'impersonation' => [
              'allowPrivateContentAccess' => true, // pre-1.19 behavior
              'log' => false,
          ],
      ],
  ],
  ```

  - Core enforces the restriction in `Content::canView()`, `ActiveQueryContent::readable()`,
    `StreamQuery::setupQuery()`, `Space::canAccessPrivateContent()`, `User::canAccessPrivateContent()`,
    `ActiveQuerySpace::visible()`, the space controller behavior and both content search drivers — content
    and spaces a module lists through those APIs are already covered. A module running **its own** visibility
    SQL (a stream filter or search backend that does not go through `ActiveQueryContent`/`StreamQuery`) must
    check `Yii::$app->user->impersonation->canAccessPrivateContent()` itself and restrict its query to
    `Content::VISIBILITY_PUBLIC`.
  - Added the `ControllerAccess::RULE_DENY_IMPERSONATED` access rule. A module whose controller exposes
    private data that is not `Content`-based (private messages, private files, …) should add
    `[ControllerAccess::RULE_DENY_IMPERSONATED]` to its `getAccessRules()` — the action is then denied
    while the impersonation restriction applies. Menu entries and widgets pointing at such a controller
    should be hidden by checking `Yii::$app->user->impersonation->canAccessPrivateContent()` in their
    event handler. See the Messenger (`mail`) module, which applies both.
  - The `humhub\modules\user\components\Impersonator` behavior of the user component was **removed**
    (no known module usage): `Yii::$app->user->impersonate($user)` → `Yii::$app->user->impersonation->start($user)`,
    `restoreImpersonator()` → `impersonation->stop()`, `isImpersonated` → `impersonation->isActive()`,
    `getImpersonator()` → `impersonation->getImpersonator()`, `canImpersonate($user)` → `impersonation->canStart($user)`.
    The model method `humhub\modules\user\models\User::canImpersonate()` was removed as well, its logic
    now lives in `Impersonation::canStart()`.
  - The impersonation state is bound to the session and fails closed: the impersonated identity never
    receives an auto-login cookie, only the impersonator's user id is stored in the session (no serialized
    record anymore), and ending an impersonation whose impersonator can no longer be resolved logs the
    session out instead of silently continuing as the impersonated user.
- Removed the unreachable `ContentContainerControllerAccess::RULE_CONTAINER_ACCESS`
  (`'containerAccess'`) rule and its validator `validateContainerAccess()`, along with the
  private helpers it alone used (`canAccessSpace()`, `getSpaceMembership()`, `canAccessUser()`,
  the `$_membership` property). The rule was registered but never added to any
  `getAccessRules()`/`getFixedRules()`, so it never ran — the space and profile visibility
  checks it duplicated are already enforced unconditionally by `space\behaviors\SpaceController`
  and `user\behaviors\ProfileController` on `EVENT_BEFORE_ACTION`. No known module referenced
  the rule. A module that added `[ContentContainerControllerAccess::RULE_CONTAINER_ACCESS]` to
  its own `getAccessRules()` must remove it; the behaviors already provide equivalent
  enforcement.
- Removed the `$right` parameter from `humhub\widgets\bootstrap\BootstrapVariationsTrait::icon()`
  (used by `Button`, `Badge`, `Link` and `Alert`). The parameter was never read by the method —
  right-aligning an icon already goes through the separate `->right()` method — so this only
  changes the signature: `icon(string|Icon|null $icon, $options = [])`, `$options` is now the
  **second** parameter instead of the third.
  **Warning:** PHP silently ignores extra arguments, so a call like `->icon('user', true)` made
  for the old `$right` flag does not fail — `true` is now passed as `$options` to `Icon::get()`,
  which fatals with "Cannot use a scalar value as an array". Audit every `->icon(...)` call that
  passes a second argument and drop it, or move a third-argument `$options` array into the second
  position.
  - `humhub\modules\ui\menu\MenuLink::setIcon()` also dropped its `$right` parameter for the same
    reason (it only forwarded into `icon()`, and `right()` on the underlying `Button`/`Badge` is
    the correct way to align an icon). `setIcon($icon, $right)` → `setIcon($icon)`.
    **Warning:** PHP silently ignores the now-unused second argument instead of erroring, so a
    call like `$menuLink->setIcon('user', true)` keeps "succeeding" but the icon is no longer
    right-aligned — audit every `->setIcon(...)` call that passes a second argument and call
    `->getLink()->right()` explicitly if right-alignment is still needed.

## Released versions

- [Version 1.18](module-migrate-1.18.md) — captcha framework, Codeception 5, mailer config keys
- [Version 1.17](module-migrate-1.17.md) — Manage-All-Content permission, CSS variables
- [Version 1.16](module-migrate-1.16.md) — search refactor, PHP 8.0 minimum
- [Version 1.15](module-migrate-1.15.md) — JS nonces, type restrictions, GUID validation
- [Legacy versions (1.14 and earlier)](module-migrate-legacy.md)
