Skip to content

[Bug]: UserController::getAction() causes OOM fatal error when user has many object references #1106

@gresa-neziri

Description

@gresa-neziri

Bug Report: UserController::getAction() causes OOM when user is referenced by many objects

Versions

Component Version
Pimcore Core v11.5.11
pimcore/admin-ui-classic-bundle v1.7.16
PHP 8.2

Description

In Pimcore\Bundle\AdminBundle\Controller\Admin\UserController::getAction(), the following code loads all referencing objects fully into memory for every user detail request:

$userObjects = DataObject\Service::getObjectsReferencingUser($user->getId());

foreach ($userObjects as $o) {
    if ($o->isAllowed('list')) {
        $userObjectData[] = [
            'path' => $o->getRealFullPath(),
            'id' => $o->getId(),
            'subtype' => $o->getClass()->getName(),
        ];
    } else {
        $hasHidden = true;
    }
}

When a user is referenced by a large number of DataObjects (e.g. hundreds or thousands), this fully hydrates every object into PHP memory just to extract 3 fields (path, id, subtype) and check one permission (isAllowed('list')). This causes PHP to exceed memory_limit and throws a fatal OOM error, making it impossible to open that user's detail page in the admin UI.


Steps to Reproduce

  1. Create a user in Pimcore admin.
  2. Assign that user to a large number of DataObjects (e.g. via a User field in a DataObject class).
  3. Open the user's detail page in the admin UI (/admin/user/get?id=X).
  4. Observe PHP fatal error: Allowed memory size exhausted.

Expected Behavior

The endpoint should handle users with many object references gracefully without risking OOM.


Suggested Fix

Short-term: COUNT guard

Perform a cheap COUNT query first. If the count exceeds a safe threshold, skip full hydration and return hasHidden: true to signal the UI that dependencies exist but cannot be fully listed:

$db = \Pimcore\Db::get();
$count = (int) $db->fetchOne(
    'SELECT COUNT(*) FROM dependencies WHERE targetid = ? AND targettype = "object"',
    [$user->getId()]
);

$threshold = 200;
$hasHidden = false;
$userObjectData = [];

if ($count <= $threshold) {
    $userObjects = DataObject\Service::getObjectsReferencingUser($user->getId());
    foreach ($userObjects as $o) {
        if ($o->isAllowed('list')) {
            $userObjectData[] = [
                'path' => $o->getRealFullPath(),
                'id' => $o->getId(),
                'subtype' => $o->getClass()->getName(),
            ];
        } else {
            $hasHidden = true;
        }
    }
} else {
    $hasHidden = true;
}

Long-term (Recommended): Paginated dependencies endpoint

The proper fix is to move object dependency loading out of getAction() entirely and into a dedicated paginated endpoint, for example:

GET /admin/user/object-dependencies?id={userId}&page=1&limit=50
#[Route(path: '/object-dependencies', name: 'object_dependencies', methods: ['GET'])]
public function objectDependenciesAction(Request $request): JsonResponse
{
    $userId = (int) $request->get('id');
    $page = max(1, (int) $request->get('page', 1));
    $limit = min(100, max(1, (int) $request->get('limit', 50)));
    $offset = ($page - 1) * $limit;

    $db = \Pimcore\Db::get();

    $total = (int) $db->fetchOne(
        'SELECT COUNT(*) FROM dependencies WHERE targetid = ? AND targettype = "object"',
        [$userId]
    );

    $rows = $db->fetchAllAssociative(
        'SELECT targetid as id FROM dependencies WHERE targetid = ? AND targettype = "object" LIMIT ? OFFSET ?',
        [$userId, $limit, $offset]
    );

    $userObjectData = [];
    $hasHidden = false;

    foreach ($rows as $row) {
        $o = DataObject::getById($row['id']);
        if ($o) {
            if ($o->isAllowed('list')) {
                $userObjectData[] = [
                    'path' => $o->getRealFullPath(),
                    'id' => $o->getId(),
                    'subtype' => $o->getClass()->getName(),
                ];
            } else {
                $hasHidden = true;
            }
        }
    }

    return $this->adminJson([
        'success' => true,
        'total' => $total,
        'page' => $page,
        'limit' => $limit,
        'hasHidden' => $hasHidden,
        'dependencies' => $userObjectData,
    ]);
}

This approach:

  • Eliminates OOM entirely — only a fixed batch of objects is hydrated per request
  • Preserves full data visibility — all dependencies are accessible via pagination
  • Keeps getAction() fast and lightweight — no heavy loading on page open
  • Scales to any number of references — no arbitrary threshold needed

The admin UI would then load dependencies lazily when the user scrolls or clicks a "Load dependencies" control, similar to how other paginated grids work in the Pimcore admin.


Impact

  • Severity: High — affects production admin usability, entire user detail page becomes inaccessible
  • Workaround: Full controller override that always returns an empty objectDependencies payload — works but hides real dependencies for all users, not just the heavy ones

Additional Context

This was discovered in a production environment where certain users are referenced by thousands of DataObjects. The root cause is that getObjectsReferencingUser() returns fully hydrated objects, and the subsequent loop instantiates all of them simultaneously in memory. Only 3 fields and 1 permission check are actually needed per object, so full hydration is unnecessary and disproportionately expensive for large datasets.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions