PHPackages                             oronts/asset-pilot-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. oronts/asset-pilot-bundle

ActivePimcore-bundle[Utility &amp; Helpers](/categories/utility)

oronts/asset-pilot-bundle
=========================

Intelligent rule-based asset organization engine for Pimcore 12 with expression language conditions, pluggable strategies, async processing, and Studio UI integration.

1.0.0(3mo ago)50AGPL-3.0-or-laterPHPPHP &gt;=8.4CI failing

Since Mar 15Pushed 3mo ago1 watchersCompare

[ Source](https://github.com/oronts/pimcore-asset-pilot-bundle)[ Packagist](https://packagist.org/packages/oronts/asset-pilot-bundle)[ RSS](/packages/oronts-asset-pilot-bundle/feed)WikiDiscussions main Synced 3w ago

READMEChangelog (1)Dependencies (5)Versions (2)Used By (0)

 [ ![Oronts](https://camo.githubusercontent.com/2cb3b0f79aec2adcbc15af45b590cb79bb44f244585a03d3eebb7130ec48c45e/68747470733a2f2f6f726f6e74732e636f6d2f5f6e6578742f696d6167653f75726c3d253246696d616765732532466c6f676f2532464c6f676f2d77686974652e706e6726773d32353626713d3735) ](https://oronts.com)

oronts/asset-pilot-bundle
=========================

[](#orontsasset-pilot-bundle)

 **Intelligent Rule-Based Asset Organization for Pimcore 12**

 [![License](https://camo.githubusercontent.com/c77148b2545a6460d987db4f36a4e1c7e4641c3d9f8ab7b25b0afbdfaddb2061/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4147504c2d2d332e302d626c75652e737667)](#license) [![Pimcore version](https://camo.githubusercontent.com/e3793943ba56af63c751a5501a4ec25b621384c0a31f659e1f8619296f5516b4/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f70696d636f72652d25354531322e302d707572706c65)](https://www.pimcore.com/) [![PHP version](https://camo.githubusercontent.com/0751c5c7ed417028d6909f393af80b98d84b224db16e8307c3988e7eefc8ecdb/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d253345253344382e342d626c7565)](https://www.php.net/) [![Symfony version](https://camo.githubusercontent.com/1b73dc6539eedd7a3e14765901a41e74c42fbca7d7d9ce4cd0007d0b53bd272a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f73796d666f6e792d253545372e302d626c61636b)](https://symfony.com/)

 [Features](#features) • [Installation](#installation) • [Configuration](#configuration) • [Examples](#configuration-examples) • [Path Templates](#path-templates) • [Expression Language](#expression-language) • [Commands](#commands) • [REST API](#rest-api) • [Permissions](#permissions) • [Studio UI](#studio-ui) • [Extending](#extending) • [Testing](#testing)

---

 [![Asset Pilot — Studio UI](docs/images/studio-ui-preview.png)](docs/images/studio-ui-preview.png)

Asset Pilot automates the organization of Pimcore assets based on configurable rules. When a DataObject is saved, Asset Pilot evaluates its asset fields against a priority-ordered rule set, resolves target paths from Twig templates, and moves files into a structured folder hierarchy. It handles localized fields, supports async processing via Symfony Messenger, logs every operation to an audit trail, and ships with a full Studio UI dashboard.

---

Features
--------

[](#features)

**Rule Engine** — Priority-based rule matching with class filtering, field targeting, expression conditions, and asset filters (type, size, extension). Rules are evaluated top-down; all matching rules produce move operations.

**Twig Path Templates** — Target paths use full Twig syntax with pre-resolved context variables (`object`, `asset`, `locale`, `className`) and custom filters (`safe_key`, `pluck`, `first_of`, `slug`, `fallback`).

**Expression Language Conditions** — Rules support Symfony ExpressionLanguage conditions with 9 built-in functions: `asset_type()`, `asset_size()`, `asset_extension()`, `object_class()`, `is_image()`, `is_video()`, `is_document()`, `has_property()`, `path_matches()`.

**Move Strategies** — Three conflict resolution strategies: `always` (move on every save), `first_assignment` (move only when no prior audit log exists), `callback` (delegate to a custom service).

**Async Processing** — Asset moves dispatch to Symfony Messenger queues with transport-level deduplication via `DeduplicateStamp`. Bulk operations run in configurable batch sizes.

**Localized Field Support** — Automatically detects localized asset fields and includes the locale in path resolution, producing per-language folder structures.

**Audit Log** — Every move operation is logged to `asset_pilot_audit_log` with source/target paths, duration, status, trigger type, and timestamps. Supports CSV export, operation reversal, and per-rule asset history.

**Unused Asset Detection** — Finds assets not referenced by any DataObject or Document. Filter by type, extension, date range, folder, file size, and confidence level. Bulk delete or move.

**Confidence Scoring** — Unused assets are classified into five confidence levels: *definitely unused* (&gt;90 days), *probably unused* (30-90 days), *recently uploaded* (&lt;30 days), *historically used* (has audit history), and *protected* (locked). Color-coded badges in the UI help prioritize cleanup.

**Asset Protection** — Lock individual assets from organization via the `asset_pilot_locked` property. Configure folder-level exclusions to protect entire directory trees.

**Search by Related Object** — Find all assets referenced by a specific DataObject via the Pimcore dependencies table. Browse assets moved by a specific rule with optional date and class filters.

**Permissions Model** — Three granular permission levels: `asset_pilot_view` (read-only), `asset_pilot_operate` (lock/unlock, bulk actions, organize), `asset_pilot_admin` (revert operations). Registered natively in Pimcore via the installer.

**Idempotency Hardening** — Multi-layer duplicate protection: Redis-backed loop guard (prevents re-entry and async ping-pong), stale job detection (compares dispatch time vs. object modification), already-at-target skip, and Symfony Messenger `DeduplicateStamp` (prevents transport-level duplicates).

**Loop Prevention** — Cache-backed guard prevents infinite recursion when asset moves trigger DataObject saves. Includes a 5-minute cooldown for recently-moved assets and 10-second dispatch deduplication.

**Config Validation** — CLI command validates all rules: class existence, field names, condition syntax, Twig template compilation, callback service registration, filter values, and duplicate priority warnings.

**Rule Debugger** — Step-by-step rule evaluation trace per object/asset pair. Shows why each rule matched or was skipped (disabled, class mismatch, field mismatch, condition failed, filter rejected).

**Studio UI Integration** — Full React dashboard integrated into Pimcore Studio via Module Federation. Six tabs: Dashboard, Rules, Operations, Audit Log, Unused Assets, Asset Management.

---

Architecture
------------

[](#architecture)

 ```
flowchart TD
    subgraph Trigger
        A([DataObject saved / Asset uploaded])
    end

    subgraph EventListener
        B{Bundle enabled?}
        C{Object class allowed?}
        D{Async enabled?}
        E["Dispatch OrganizeAssetsMessage
        to Messenger queue
        with DeduplicateStamp"]
        F[Call AssetOrganizer directly]
    end

    subgraph AssetOrganizer
        G["Check LoopGuard (prevent re-entry)"]
        H{Already processing?}
        I["Check asset_pilot_locked property"]
        J{Asset locked?}
        K[Skip — log as skipped]
        L[Mark object as processing]
    end

    subgraph AssetFieldExtractor
        M[Scan all object fields]
        N["Return AssetFieldInfo[ ]"]
    end

    subgraph RuleEngine
        O[Load rules sorted by priority desc]
        P{Class matches?}
        Q{Field matches?}
        R{Condition passes?}
        S{Filters accept?}
        T["Create RuleMatch"]
        U["Return RuleMatch[ ]"]
    end

    subgraph PathResolver
        V["Render Twig template
        with object/asset context"]
        W[Sanitize path segments]
        X[Return resolved path]
    end

    subgraph StrategyResolver
        Y{Strategy type?}
        Z[Proceed with move]
        AA{Audit log exists?}
        AB[Skip — already assigned]
        AC[Delegate to custom service]
    end

    subgraph MoveExecution
        AD["Dispatch PRE_MOVE event"]
        AE{Event cancelled?}
        AF["Generate safe filename (NamingStrategy)"]
        AG[Move asset to target path]
        AH["Dispatch POST_MOVE event"]
    end

    subgraph AuditLogger
        AI["Log operation to
        asset_pilot_audit_log
        (asset, object, paths, status, duration)"]
    end

    A --> B
    B -- yes --> C
    B -- no --> STOP1([Stop])
    C -- yes --> D
    C -- no --> STOP2([Stop])
    D -- yes --> E
    D -- no --> F
    E --> G
    F --> G
    G --> H
    H -- yes --> STOP3([Stop])
    H -- no --> I
    I --> J
    J -- yes --> K --> STOP4([Stop])
    J -- no --> L

    L --> M
    M --> N

    N --> O
    O --> P
    P -- yes --> Q
    P -- no --> O
    Q -- yes --> R
    Q -- no --> O
    R -- yes --> S
    R -- no --> O
    S -- yes --> T --> O
    S -- no --> O
    O -. all assets and rules evaluated .-> U

    U --> V --> W --> X

    X --> Y
    Y -- always --> Z
    Y -- first_assignment --> AA
    Y -- callback --> AC
    AA -- yes --> AB --> STOP5([Stop])
    AA -- no --> Z
    AC --> Z

    Z --> AD --> AE
    AE -- yes --> STOP6([Stop])
    AE -- no --> AF --> AG --> AH

    AH --> AI

    style Trigger fill:#E8F4FD,stroke:#333
    style EventListener fill:#E8F4FD,stroke:#333
    style AssetOrganizer fill:#E8F4FD,stroke:#333
    style AssetFieldExtractor fill:#E8F4FD,stroke:#333
    style RuleEngine fill:#E8F4FD,stroke:#333
    style PathResolver fill:#E8F4FD,stroke:#333
    style StrategyResolver fill:#E8F4FD,stroke:#333
    style MoveExecution fill:#E8F4FD,stroke:#333
    style AuditLogger fill:#E8F4FD,stroke:#333
```

      Loading > **Note:** Extracts assets from: image, video, document fields · gallery, hotspotimage fields · relation fields (many-to-one, many-to-many) · localized fields (all locales)

```
src/
├── Audit/                  AuditLogger — database-backed operation logging
├── Command/                CLI: organize, debug-rule, validate-config, status, audit, cleanup-unused
├── Condition/              ConditionEvaluatorInterface + ExpressionLanguage impl
├── Controller/Api/         REST API controllers (6 controllers, 25+ endpoints)
├── DependencyInjection/    Bundle configuration tree + service loading
├── Dto/                    API request/response DTOs
├── Engine/                 RuleEngine — core matching + explain logic
├── Enum/                   MoveStrategy, OperationStatus, TriggerType, AssetPilotPermission
├── Event/                  AssetMoveEvent + event constants
├── EventListener/          DataObject save + Asset upload listeners (with DeduplicateStamp)
├── Filter/                 AssetFilterInterface + type/size/extension/composite
├── Message/                Messenger messages: OrganizeAssets, BulkOrganize
├── MessageHandler/         Async handlers with stale job detection
├── Model/                  Rule, RuleMatch, RuleEvaluation, MoveOperation, OperationResult
├── Naming/                 NamingStrategyInterface + SafeNamingStrategy
├── PathResolver/           PathResolverInterface + Twig TemplatePathResolver
├── Service/                AssetOrganizer, AssetFieldExtractor, AssetSearchService,
│                           AssetPropertyService, UnusedAssetFinder, ConfidenceScorer,
│                           ConfigValidator, LoopGuard
├── Strategy/               ConflictStrategyInterface + Always/FirstAssignment/Callback
├── Webpack/                Module Federation entry point provider
├── Installer.php           Database schema + permission registration
└── OrontsAssetPilotBundle.php

```

---

Installation
------------

[](#installation)

### 1. Require the package

[](#1-require-the-package)

```
composer require oronts/asset-pilot-bundle
```

### 2. Enable the bundle

[](#2-enable-the-bundle)

Add to `config/bundles.php`:

```
return [
    // ...
    Oronts\AssetPilotBundle\OrontsAssetPilotBundle::class => ['all' => true],
];
```

### 3. Install the database table and permissions

[](#3-install-the-database-table-and-permissions)

```
bin/console pimcore:bundle:install OrontsAssetPilotBundle
```

This creates:

- The `asset_pilot_audit_log` table with indexes on `asset_id`, `object_id`, `rule_name`, `status`, and `created_at`
- Three Pimcore permissions: `asset_pilot_view`, `asset_pilot_operate`, `asset_pilot_admin`

### 4. Configure Messenger transport

[](#4-configure-messenger-transport)

Add the transport and routing to `config/packages/messenger.yaml`:

```
framework:
    messenger:
        transports:
            asset_pilot:
                dsn: '%messenger.dsn%/asset_pilot'
                retry_strategy:
                    max_retries: 3
                    delay: 2000
                    multiplier: 3
                    max_delay: 30000

        routing:
            'Oronts\AssetPilotBundle\Message\OrganizeAssetsMessage': asset_pilot
            'Oronts\AssetPilotBundle\Message\BulkOrganizeMessage': asset_pilot
```

### 5. Configure Symfony Lock (required for message deduplication)

[](#5-configure-symfony-lock-required-for-message-deduplication)

```
# config/packages/lock.yaml
framework:
    lock: 'redis://%env(REDIS_HOST)%'
```

### 6. Build the Studio UI assets

[](#6-build-the-studio-ui-assets)

```
bin/console assets:install
bin/console cache:clear
```

---

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

[](#configuration)

Create `config/packages/oronts_asset_pilot.yaml`:

```
oronts_asset_pilot:
    enabled: true

    rules:
        product_images:
            class: Product
            fields: [images, galleryImages]
            condition: 'object.getItemNumber() != null'
            target_path: '/Products/{{ object.getItemNumber() }}/Images'
            strategy: always
            priority: 100
            filters:
                types: [image]
                max_size: 52428800

        product_documents:
            class: Product
            fields: [datasheet, manual, brochure]
            target_path: '/Products/{{ object.getItemNumber() }}/Documents{{ locale ? "/" ~ locale : "" }}'
            strategy: always
            priority: 70

    strategies:
        default: always

    naming:
        collision_pattern: counter
        slugify: true

    async:
        enabled: true
        batch_size: 50

    audit:
        enabled: true
        retention_days: 90

    protection:
        exclude_folders:
            - /Protected/
            - /Manual/
        lock_property: asset_pilot_locked

    logging:
        channel: asset_pilot
```

### Configuration Reference

[](#configuration-reference)

KeyTypeDefaultDescription`enabled``bool``true`Global on/off switch`rules``map``[]`Named rule definitions (see below)`strategies.default``enum``always`Default strategy: `always`, `first_assignment`, `callback``naming.collision_pattern``enum``counter`Filename collision resolution: `counter`, `timestamp`, `uuid``naming.slugify``bool``true`Slugify filenames during organization`async.enabled``bool``true`Dispatch moves via Symfony Messenger`async.batch_size``int``50`Operations per batch message`audit.enabled``bool``true`Enable audit logging`audit.retention_days``int``90`Days to retain audit entries`protection.exclude_folders``string[]``[]`Folders excluded from organization (e.g., `["/Protected/"]`)`protection.lock_property``string``asset_pilot_locked`Custom property name used to lock assets`logging.channel``string``asset_pilot`Monolog channel name### Rule Options

[](#rule-options)

KeyTypeRequiredDefaultDescription`class``string`yes—DataObject class name or `*` for wildcard`fields``string[]`no`[]`Field names to match. Empty = all asset fields`condition``string`no`null`ExpressionLanguage condition`target_path``string`yes—Twig path template`strategy``enum`no`always``always`, `first_assignment`, `callback``callback``string`no`null`Service ID (required when strategy is `callback`)`priority``int`no`10`Higher values match first`enabled``bool`no`true`Enable/disable individual rules`filters.types``string[]`no`[]`Asset types: `image`, `video`, `document`, etc.`filters.min_size``int`no`null`Minimum file size in bytes`filters.max_size``int`no`null`Maximum file size in bytes`filters.extensions``string[]`no`[]`Allowed file extensions---

Configuration Examples
----------------------

[](#configuration-examples)

### E-commerce: Product Assets by Item Number

[](#e-commerce-product-assets-by-item-number)

Organize product images and documents into folders named by item number. Localized documents (datasheets, manuals) get a locale subfolder.

```
oronts_asset_pilot:
    rules:
        product_images:
            class: Product
            fields: [images, galleryImages, thumbnails]
            condition: 'object.getItemNumber() != null'
            target_path: '/Products/{{ object.getItemNumber() }}/Images'
            strategy: always
            priority: 100
            filters:
                types: [image]
                extensions: [jpg, png, webp]
                max_size: 52428800

        product_documents:
            class: Product
            fields: [datasheet, manual, brochure]
            condition: 'object.getItemNumber() != null'
            target_path: '/Products/{{ object.getItemNumber() }}/Documents{{ locale ? "/" ~ locale : "" }}'
            strategy: always
            priority: 80

        product_videos:
            class: Product
            fields: [productVideo, tutorialVideo]
            target_path: '/Products/{{ object.getItemNumber() }}/Media'
            strategy: always
            priority: 60
            filters:
                types: [video]
```

Resulting folder structure:

```
/Products/
├── ART-10042/
│   ├── Images/
│   │   ├── product-front.jpg
│   │   └── product-back.png
│   ├── Documents/
│   │   ├── en/
│   │   │   └── datasheet-en.pdf
│   │   └── de/
│   │       └── datasheet-de.pdf
│   └── Media/
│       └── tutorial.mp4
└── ART-10043/
    └── ...

```

### Category-Based Hierarchy

[](#category-based-hierarchy)

Organize assets into folders derived from the object's category relation. Uses `first_of` to grab the category key, with a fallback for uncategorized products.

```
oronts_asset_pilot:
    rules:
        category_images:
            class: Product
            fields: [images]
            target_path: >-
                /Catalog/{{ object.getCategories()|first_of("key", "Uncategorized") }}/{{ object.getItemNumber()|fallback("unknown") }}/Images
            strategy: always
            priority: 100
            filters:
                types: [image]

        category_documents:
            class: Product
            fields: [datasheet, certificate]
            target_path: >-
                /Catalog/{{ object.getCategories()|first_of("key", "Uncategorized") }}/{{ object.getItemNumber()|fallback("unknown") }}/Docs{{ locale ? "/" ~ locale : "" }}
            strategy: always
            priority: 80
```

Resulting folder structure:

```
/Catalog/
├── Electronics/
│   ├── ART-10042/
│   │   ├── Images/
│   │   └── Docs/
│   │       ├── en/
│   │       └── de/
│   └── ART-10043/
│       └── ...
├── Furniture/
│   └── ...
└── Uncategorized/
    └── ...

```

### Multi-Class Setup

[](#multi-class-setup)

Apply rules to different DataObject classes. Use the `*` wildcard to create a catch-all rule for any class that doesn't have a specific rule.

```
oronts_asset_pilot:
    rules:
        product_assets:
            class: Product
            target_path: '/Products/{{ object.getItemNumber()|fallback(object.getKey()) }}/Assets'
            strategy: always
            priority: 100
            filters:
                types: [image, document]

        category_banners:
            class: Category
            fields: [bannerImage, icon]
            target_path: '/Categories/{{ object.getKey() }}'
            strategy: first_assignment
            priority: 90
            filters:
                types: [image]

        brand_logos:
            class: Brand
            fields: [logo, headerImage]
            target_path: '/Brands/{{ object.getName()|slug }}'
            strategy: first_assignment
            priority: 80

        catch_all:
            class: '*'
            target_path: '/Assets/{{ className }}/{{ object.getKey()|safe_key }}'
            strategy: always
            priority: 1
```

### First Assignment Strategy

[](#first-assignment-strategy)

Use `first_assignment` when assets should only be organized on first save. Once moved, they stay put even if the object is updated. Useful for QR codes, generated certificates, or any asset that should not be relocated after initial placement.

```
oronts_asset_pilot:
    rules:
        generated_qrcode:
            class: Product
            fields: [qrCode]
            target_path: '/Products/{{ object.getItemNumber() }}/QR{{ locale ? "/" ~ locale : "" }}'
            strategy: first_assignment
            priority: 50
            filters:
                types: [image]

        certificates:
            class: Product
            fields: [certificate, testReport]
            target_path: '/Products/{{ object.getItemNumber() }}/Certificates'
            strategy: first_assignment
            priority: 40
            filters:
                types: [document]
                extensions: [pdf]
```

### Callback Strategy with Custom Logic

[](#callback-strategy-with-custom-logic)

Delegate the move decision to a custom service. The service receives the asset, object, and rule, and returns `true` to proceed or `false` to skip.

```
oronts_asset_pilot:
    rules:
        conditional_move:
            class: Product
            target_path: '/Products/{{ object.getItemNumber() }}/Images'
            strategy: callback
            callback: App\AssetPilot\Strategy\ApprovalStrategy
            priority: 100
```

```
// src/AssetPilot/Strategy/ApprovalStrategy.php
class ApprovalStrategy implements ConflictStrategyInterface
{
    public function resolve(Asset $asset, AbstractObject $object, Rule $rule): bool
    {
        // Only move assets for published objects
        if ($object instanceof Concrete && !$object->isPublished()) {
            return false;
        }

        // Only move during business hours
        $hour = (int) date('H');
        return $hour >= 8 && $hour < 18;
    }

    public function supports(MoveStrategy $strategy): bool
    {
        return $strategy === MoveStrategy::Callback;
    }
}
```

### Sync Mode (No Messenger Queue)

[](#sync-mode-no-messenger-queue)

Disable async processing to move assets immediately during the save request. Suitable for development environments or small catalogs where move operations are fast.

```
oronts_asset_pilot:
    async:
        enabled: false

    rules:
        product_images:
            class: Product
            target_path: '/Products/{{ object.getKey() }}/Images'
            strategy: always
            priority: 10
```

### Asset Protection

[](#asset-protection)

Lock specific folders from automation and configure the lock property name.

```
oronts_asset_pilot:
    protection:
        exclude_folders:
            - /Protected/
            - /Manual/
            - /Brand-Assets/
        lock_property: asset_pilot_locked
```

Assets in excluded folders are never processed. Individual assets can be locked/unlocked via the API (`POST /assets/{id}/lock`, `DELETE /assets/{id}/lock`) or the Studio UI.

### Date-Based Organization

[](#date-based-organization)

Organize uploads by year and month. Useful for editorial content, blog posts, or any time-based content.

```
oronts_asset_pilot:
    rules:
        blog_images:
            class: BlogPost
            fields: [heroImage, contentImages]
            target_path: '/Blog/{{ date.format("Y") }}/{{ date.format("m") }}/{{ object.getKey()|slug }}'
            strategy: always
            priority: 100
            filters:
                types: [image]

        news_attachments:
            class: NewsArticle
            target_path: '/News/{{ date.format("Y/m/d") }}/{{ object.getKey()|slug }}'
            strategy: always
            priority: 90
```

### Restrictive Filters

[](#restrictive-filters)

Combine type, extension, and size filters to tightly control which assets a rule processes.

```
oronts_asset_pilot:
    rules:
        high_res_photos:
            class: Product
            fields: [images]
            target_path: '/Products/{{ object.getItemNumber() }}/HighRes'
            strategy: always
            priority: 100
            filters:
                types: [image]
                extensions: [jpg, tiff, png]
                min_size: 1048576      # at least 1 MB
                max_size: 104857600    # max 100 MB

        small_thumbnails:
            class: Product
            fields: [thumbnail]
            target_path: '/Products/{{ object.getItemNumber() }}/Thumbs'
            strategy: always
            priority: 90
            filters:
                types: [image]
                extensions: [jpg, png, webp]
                max_size: 1048576      # under 1 MB
```

---

Path Templates
--------------

[](#path-templates)

Target paths use Twig syntax. The resolver provides these context variables:

VariableTypeDescription`object``AbstractObject`The DataObject being saved`asset``Asset`The asset being organized`locale``?string`Locale code for localized fields (`en`, `de`, etc.) or `null``date``DateTimeImmutable`Current date/time`className``string`DataObject class nameYou can call any method on the `object` and `asset` variables directly in the template. The resolver handles null values gracefully and falls back to `'unknown'` for empty segments.

### Custom Filters

[](#custom-filters)

FilterUsageDescription`safe_key``{{ value|safe_key }}`Replace non-alphanumeric chars with `-``pluck``{{ items|pluck('key') }}`Extract a property from each array item`first_of``{{ items|first_of('key') }}`Get property from first item, fallback to `'unknown'``slug``{{ value|slug }}`URL-safe lowercase slug`fallback``{{ value|fallback('default') }}`Return fallback when value is empty/null`trim_path``{{ value|trim_path }}`Strip leading/trailing slashes### Custom Functions

[](#custom-functions)

FunctionUsageDescription`coalesce``{{ coalesce(a, b, c) }}`First non-null, non-empty value`prop``{{ prop(obj, 'method', arg1) }}`Safely call a method on an object`rel``{{ rel(object, 'categories', 0) }}`Safely access a relation by index`has_relation``{% if has_relation(object, 'categories') %}`Check if relation has items### Path Template Examples

[](#path-template-examples)

```
# Simple flat structure
target_path: '/Products/{{ object.getItemNumber() }}/Images'

# Category hierarchy
target_path: '/Products/{{ object.getCategories()|first_of("key", "Uncategorized") }}/{{ object.getItemNumber() }}/Images'

# Locale-aware paths for localized fields
target_path: '/Products/{{ object.getItemNumber() }}/Documents{{ locale ? "/" ~ locale : "" }}'

# Date-based organization
target_path: '/Uploads/{{ date.format("Y/m") }}/{{ className }}'

# Conditional logic with Twig
target_path: '{% if has_relation(object, "categories") %}/Products/{{ object.getCategories()|first_of("key") }}{% else %}/Products/Uncategorized{% endif %}/Assets'

# Joined relation path
target_path: '/Products/{{ object.getCategories()|pluck("key")|join("/") }}/Media'

# Coalesce multiple possible identifiers
target_path: '/Products/{{ coalesce(prop(object, "getItemNumber"), prop(object, "getSku"), object.getKey()) }}/Assets'

# Fallback with slug
target_path: '/{{ className }}/{{ object.getName()|slug|fallback("unnamed") }}'
```

---

Expression Language
-------------------

[](#expression-language)

Rule conditions use Symfony ExpressionLanguage. Three variables are available: `object`, `asset`, and `rule`.

### Built-in Functions

[](#built-in-functions)

FunctionReturnsExample`asset_type(asset)``string``asset_type(asset) == "image"``asset_size(asset)``int``asset_size(asset) > 1048576``asset_extension(asset)``string``asset_extension(asset) == "pdf"``object_class(object)``string``object_class(object) == "Product"``is_image(asset)``bool``is_image(asset)``is_video(asset)``bool``is_video(asset)``is_document(asset)``bool``is_document(asset)``has_property(element, name)``bool``has_property(asset, "source")``path_matches(asset, pattern)``bool``path_matches(asset, "#/temp/#")`### Condition Examples

[](#condition-examples)

```
# Only objects that have an item number set
condition: 'object.getItemNumber() != null'

# Only images under 10 MB
condition: 'is_image(asset) and asset_size(asset) < 10485760'

# Only PDF files
condition: 'asset_extension(asset) == "pdf"'

# Compound: images over 1 MB from Product class
condition: 'is_image(asset) and asset_size(asset) > 1048576 and object_class(object) == "Product"'

# Skip assets already in the target structure
condition: 'not path_matches(asset, "#^/Products/#")'

# Only process assets with a specific property
condition: 'has_property(asset, "approved") and has_property(asset, "reviewed")'

# Match only videos or documents (no images)
condition: 'is_video(asset) or is_document(asset)'
```

---

Commands
--------

[](#commands)

### Organize Assets

[](#organize-assets)

```
# Organize a single object
bin/console asset-pilot:organize --object-id=42

# Dry run (preview without moving)
bin/console asset-pilot:organize --object-id=42 --dry-run

# Bulk organize all objects of a class
bin/console asset-pilot:organize --class=Product

# Async bulk (dispatch to messenger queue)
bin/console asset-pilot:organize --class=Product --async --batch-size=100

# Verbose dry run — shows full rule evaluation per asset
bin/console asset-pilot:organize --object-id=42 --dry-run -v
```

### Validate Configuration

[](#validate-configuration)

```
# Validate all configured rules
bin/console asset-pilot:validate-config
```

Checks performed:

- Class exists in Pimcore (`ClassDefinition::getByName()`)
- Fields exist in the class definition (including localized fields)
- ExpressionLanguage condition syntax is valid
- Twig path template syntax is valid
- Callback service exists in the DI container (when strategy=callback)
- Filter type/extension values are valid
- Warns on duplicate priorities for the same class

### Debug Rules

[](#debug-rules)

```
# Debug rule evaluation for a specific object (shows all rules and why they matched/skipped)
bin/console asset-pilot:debug-rule --object-id=42

# Debug a specific asset against all rules
bin/console asset-pilot:debug-rule --object-id=42 --asset-id=100

# Filter to a single rule
bin/console asset-pilot:debug-rule --object-id=42 --rule=product_images

# Filter to a specific field
bin/console asset-pilot:debug-rule --object-id=42 --field=productImages
```

Output shows a table per rule with: rule name, result (MATCHED/SKIPPED), rejection reason (disabled, class\_mismatch, field\_mismatch, condition\_failed, filter\_rejected), condition expression and result, resolved target path, and priority.

### View Status

[](#view-status)

```
# Show configured rules and statistics
bin/console asset-pilot:status

# JSON output for scripting
bin/console asset-pilot:status --format=json
```

### Audit Log

[](#audit-log)

```
# Recent operations
bin/console asset-pilot:audit --limit=50

# Filter by class and status
bin/console asset-pilot:audit --class=Product --status=completed --since="1 week ago"

# Filter by rule
bin/console asset-pilot:audit --rule=product_images

# Clean up old entries (respects retention_days config)
bin/console asset-pilot:audit --cleanup
```

### Clean Up Unused Assets

[](#clean-up-unused-assets)

```
# Preview unused assets
bin/console asset-pilot:cleanup-unused --dry-run

# Delete unused images older than 90 days
bin/console asset-pilot:cleanup-unused --type=image --before="-90 days" --action=delete

# Move unused assets to archive folder
bin/console asset-pilot:cleanup-unused --action=move --move-to="/Archive/Unused"

# Filter by size and extension
bin/console asset-pilot:cleanup-unused --min-size=1048576 --extension=jpg,png --dry-run
```

### Scheduled Jobs (Cron)

[](#scheduled-jobs-cron)

All commands can be automated via cron. Example crontab entries:

```
# --- Nightly ---
# Organize all Product assets (async, processed by messenger workers)
0 2 * * * cd /var/www/html && bin/console asset-pilot:organize --class=Product --async --batch-size=100 >> /var/log/asset-pilot.log 2>&1

# --- Weekly ---
# Generate unused asset report (dry-run, no changes)
0 3 * * 0 cd /var/www/html && bin/console asset-pilot:cleanup-unused --dry-run --type=image 2>&1 | mail -s "Unused Assets Report" admin@example.com

# Archive unused images older than 90 days
0 4 * * 0 cd /var/www/html && bin/console asset-pilot:cleanup-unused --type=image --before="-90 days" --action=move --move-to="/Archive/Unused" >> /var/log/asset-pilot.log 2>&1

# --- Monthly ---
# Clean up old audit log entries
0 5 1 * * cd /var/www/html && bin/console asset-pilot:audit --cleanup >> /var/log/asset-pilot.log 2>&1

# --- CI/CD ---
# Validate config after deployments
# bin/console asset-pilot:validate-config
# bin/console asset-pilot:debug-rule --object-id=42
```

For Kubernetes/Docker environments, use CronJob resources or container-level cron scheduling.

---

Permissions
-----------

[](#permissions)

Asset Pilot registers three permissions in Pimcore's native permission system during installation. Assign them to user roles via **Settings &gt; Users / Roles &gt; Permissions**.

PermissionKeyDescription**View**`asset_pilot_view`View dashboard, rules, audit log, unused assets, search assets. All read-only endpoints.**Operate**`asset_pilot_operate`Organize assets, lock/unlock, bulk tag, bulk set properties, delete/move unused assets.**Admin**`asset_pilot_admin`Revert completed operations from the audit log.All API endpoints enforce permissions via `#[IsGranted(AssetPilotPermission::*)]` attributes. The Studio UI hides action buttons when the user lacks the required permission.

The `GET /permissions` endpoint returns the current user's permission set:

```
{
    "view": true,
    "operate": true,
    "admin": false
}
```

---

REST API
--------

[](#rest-api)

All endpoints are prefixed with `/pimcore-studio/api/asset-pilot`. Requires Pimcore Studio authentication. Permissions are enforced on every endpoint (see [Permissions](#permissions)).

### Dashboard

[](#dashboard)

MethodEndpointPermissionDescription`GET``/dashboard`ViewDashboard statistics`GET``/dashboard/class-stats`ViewPer-class breakdown`GET``/permissions`—Current user's permission set### Operations

[](#operations)

MethodEndpointPermissionDescription`POST``/organize`OperateOrganize a single object`POST``/organize/preview`ViewDry-run preview`POST``/organize/explain`ViewDetailed rule evaluation per asset`POST``/organize/bulk`OperateBulk organize by class or IDs`POST``/operations/bulk-preview`ViewPaginated bulk preview`GET``/operations/status`ViewOperation statistics#### Organize request body

[](#organize-request-body)

```
{
    "objectId": 42,
    "dryRun": false,
    "async": true
}
```

#### Bulk organize request body

[](#bulk-organize-request-body)

```
{
    "className": "Product",
    "async": true,
    "batchSize": 50
}
```

Or with explicit IDs:

```
{
    "objectIds": [1, 2, 3, 4, 5],
    "async": true,
    "batchSize": 50
}
```

#### Explain response

[](#explain-response)

```
{
    "objectId": 42,
    "operations": [{
        "assetId": 456,
        "sourcePath": "/uploads/photo.jpg",
        "targetPath": "/Products/ART-123/Images/photo.jpg",
        "ruleName": "product_images",
        "status": "completed"
    }],
    "evaluations": [{
        "assetId": 456,
        "assetPath": "/uploads/photo.jpg",
        "fieldName": "images",
        "locale": null,
        "ruleName": "product_images",
        "matched": true,
        "rejectionReason": null,
        "conditionExpression": "object.getItemNumber() != null",
        "conditionResult": true,
        "conditionError": null,
        "filterDetails": null,
        "resolvedPath": "/Products/ART-123/Images",
        "priority": 100,
        "enabled": true
    }, {
        "assetId": 456,
        "assetPath": "/uploads/photo.jpg",
        "fieldName": "images",
        "locale": null,
        "ruleName": "product_documents",
        "matched": false,
        "rejectionReason": "filter_rejected",
        "conditionExpression": null,
        "conditionResult": true,
        "conditionError": null,
        "filterDetails": "type mismatch: image not in [document]",
        "resolvedPath": null,
        "priority": 70,
        "enabled": true
    }]
}
```

### Rules

[](#rules)

MethodEndpointPermissionDescription`GET``/rules`ViewList all configured rules`GET``/rules/{name}`ViewRule details with statistics`GET``/rules/{name}/preview?objectId=42`ViewPreview rule against an object### Asset Management

[](#asset-management)

MethodEndpointPermissionDescription`GET``/assets/search`ViewSearch assets (params: `q`, `type`, `folder`, `objectId`, `page`, `limit`, `sort`, `order`)`GET``/assets/by-object/{objectId}`ViewAssets linked to a DataObject via dependencies (params: `page`, `limit`, `type`)`GET``/assets/tags`ViewList all available Pimcore tags`GET``/assets/{id}/tags`ViewGet tags for a specific asset`POST``/assets/{id}/lock`OperateLock asset from organization`DELETE``/assets/{id}/lock`OperateUnlock asset`POST``/assets/bulk-tag`OperateBulk assign tags to assets`POST``/assets/bulk-property`OperateBulk set custom properties#### Search parameters

[](#search-parameters)

ParameterTypeDescription`q``string`Search by filename or path (LIKE match)`type``string`Filter by asset type: `image`, `document`, `video`, `audio`, `text`, `archive``folder``string`Filter by folder path (e.g., `/Products/`)`objectId``int`Filter to assets referenced by a specific DataObject (via dependencies table)`page``int`Page number (default: 1)`limit``int`Items per page (default: 50, max: 200)`sort``string`Sort field: `id`, `filename`, `type`, `file_size`, `modified_at``order``string`Sort order: `asc` or `desc`#### Bulk tag request body

[](#bulk-tag-request-body)

```
{
    "assetIds": [1, 2, 3],
    "tagIds": [10, 20],
    "replace": false
}
```

Set `replace: true` to remove all existing tags before assigning new ones.

#### Bulk property request body

[](#bulk-property-request-body)

```
{
    "assetIds": [1, 2, 3],
    "name": "department",
    "type": "text",
    "data": "Marketing"
}
```

Supported types: `text`, `bool`, `select`.

### Unused Assets

[](#unused-assets)

MethodEndpointPermissionDescription`GET``/unused-assets`ViewList unused assets with confidence scoring`GET``/unused-assets/stats`ViewUnused asset statistics by type`POST``/unused-assets/bulk-delete`OperateDelete unused assets`POST``/unused-assets/bulk-move`OperateMove unused assets to folder#### Unused assets parameters

[](#unused-assets-parameters)

ParameterTypeDescription`type``string`Filter by asset type`extension``string`Comma-separated extensions (e.g., `pdf,png,jpg`)`before``string`Modified before date (ISO format)`after``string`Modified after date (ISO format)`folder``string`Filter by folder path`minSize``int`Minimum file size in bytes`maxSize``int`Maximum file size in bytes`confidence``string`Filter by confidence: `definitely_unused`, `probably_unused`, `recently_uploaded`, `historically_used`, `protected``page``int`Page number (default: 1)`limit``int`Items per page (default: 50, max: 200)`sort``string`Sort field`order``string`Sort order: `asc` or `desc`#### Confidence levels

[](#confidence-levels)

LevelCriteriaRecommended Action`definitely_unused`No references, last modified &gt;90 days agoSafe to delete`probably_unused`No references, last modified 30-90 days agoReview before deleting`recently_uploaded`No references, last modified &lt;30 days agoWait — may be in use soon`historically_used`No references, but has audit history of past movesInvestigate before deleting`protected`Has `asset_pilot_locked` propertyExcluded from cleanup### Audit Log

[](#audit-log-1)

MethodEndpointPermissionDescription`GET``/audit`ViewPaginated audit entries (params: `page`, `limit`, `class`, `status`, `ruleName`, `sort`, `order`)`GET``/audit/stats`ViewAudit statistics`GET``/audit/export`ViewExport as CSV (params: `class`, `status`, `ruleName`)`GET``/audit/by-rule/{ruleName}/assets`ViewDistinct assets moved by a rule (params: `page`, `limit`, `since`, `class`)`POST``/audit/{id}/revert`AdminRevert a completed operation#### Assets by rule parameters

[](#assets-by-rule-parameters)

ParameterTypeDescription`since``string`Only include moves after this date (ISO format, e.g., `2026-02-01`)`class``string`Filter by object class name`page``int`Page number (default: 1)`limit``int`Items per page (default: 50)---

Studio UI
---------

[](#studio-ui)

Asset Pilot integrates into Pimcore Studio as a Module Federation remote. The UI provides six tabs:

TabDescription**Dashboard**Statistics overview (organized, pending, failed, skipped counts), class breakdown table, recent operations list**Rules**View all configured rules with priority, strategy, target path. Detail modal with configuration and statistics. Preview modal to test a rule against a specific object ID.**Operations**Single object organize (with dry-run, async, and explain modes). Bulk organize by class with paginated preview and "Organize All" button. System status with refresh.**Audit Log**Full operation history with sorting. Filter by class, status, and rule name. CSV export. Revert individual operations.**Unused Assets**Confidence-scored unused asset list with color-coded badges. Filter by type, extensions, date range, folder, size, and confidence level. Bulk delete or move selected assets. Filter presets.**Asset Management**Search assets by filename/path, filter by type, folder, or Object ID. Lock/unlock assets. Bulk assign tags. Bulk set custom properties. Sortable columns with pagination.### Confidence Badges

[](#confidence-badges)

Unused assets display color-coded confidence badges:

BadgeColorMeaningDefinitely UnusedGreenSafe to clean up (&gt;90 days, no references)Probably UnusedYellowReview recommended (30-90 days)Recently UploadedRedWait before action (&lt;30 days)Historically UsedOrangeWas previously organized — investigateProtectedGrayLocked asset, excluded from cleanup### Localization

[](#localization)

The Studio UI ships with English and German translations. All UI strings use the `asset-pilot.*` i18n namespace.

---

Idempotency &amp; Loop Prevention
---------------------------------

[](#idempotency--loop-prevention)

Asset Pilot uses a multi-layer approach to prevent duplicate processing:

LayerMechanismTTLPurpose**Loop Guard**Redis cache (`cache.app`)60sPrevents re-entry when asset moves trigger DataObject saves**Recently Moved**Redis cache300s (5 min)Prevents async ping-pong for shared assets between objects**Dispatch Dedup**Redis cache10sPrevents burst duplicate dispatches from rapid saves**Stale Job Detection**`dispatchedAt` timestamp—Skips processing if object was modified after message dispatch**Already-at-Target**Path comparison—Skips move when source path equals target path**DeduplicateStamp**Symfony Lock (Redis)30s/60sTransport-level deduplication prevents the same message from being processed twiceThe `DeduplicateStamp` TTLs:

- **30 seconds** for single-object organize messages (`asset_pilot_organize_{objectId}`)
- **60 seconds** for bulk organize messages (`asset_pilot_bulk_{batchHash}`)

---

Extending
---------

[](#extending)

Asset Pilot is built around interfaces. Swap out any component by implementing the interface and registering it as a service.

### Custom Filter

[](#custom-filter)

Restrict which assets a rule applies to. All registered filters run inside `CompositeFilter` using AND logic — the first rejection short-circuits evaluation.

```
namespace App\AssetPilot\Filter;

use Oronts\AssetPilotBundle\Filter\AssetFilterInterface;
use Oronts\AssetPilotBundle\Model\Rule;
use Pimcore\Model\Asset;
use Pimcore\Model\DataObject\AbstractObject;

class PublishedOnlyFilter implements AssetFilterInterface
{
    public function accept(Asset $asset, AbstractObject $object, Rule $rule): bool
    {
        if ($object instanceof \Pimcore\Model\DataObject\Concrete) {
            return $object->isPublished();
        }
        return true;
    }
}
```

```
services:
    App\AssetPilot\Filter\PublishedOnlyFilter:
        tags:
            - { name: 'oronts_asset_pilot.filter' }
```

### Custom Strategy

[](#custom-strategy)

Control when assets should be moved. Implement `ConflictStrategyInterface` and register with an alias.

```
namespace App\AssetPilot\Strategy;

use Oronts\AssetPilotBundle\Enum\MoveStrategy;
use Oronts\AssetPilotBundle\Model\Rule;
use Oronts\AssetPilotBundle\Strategy\ConflictStrategyInterface;
use Pimcore\Model\Asset;
use Pimcore\Model\DataObject\AbstractObject;

class BusinessHoursStrategy implements ConflictStrategyInterface
{
    public function resolve(Asset $asset, AbstractObject $object, Rule $rule): bool
    {
        $hour = (int) date('H');
        return $hour >= 9 && $hour < 17;
    }

    public function supports(MoveStrategy $strategy): bool
    {
        return $strategy === MoveStrategy::Callback;
    }
}
```

```
services:
    App\AssetPilot\Strategy\BusinessHoursStrategy:
        tags:
            - { name: 'oronts_asset_pilot.strategy', alias: 'business_hours' }
```

Reference it in a rule config:

```
oronts_asset_pilot:
    rules:
        controlled_move:
            class: Product
            target_path: '/Products/{{ object.getItemNumber() }}/Images'
            strategy: callback
            callback: App\AssetPilot\Strategy\BusinessHoursStrategy
```

### Custom Path Resolver

[](#custom-path-resolver)

Replace the Twig-based path resolution entirely. Implement `PathResolverInterface` and override the service alias.

```
namespace App\AssetPilot\PathResolver;

use Oronts\AssetPilotBundle\Model\Rule;
use Oronts\AssetPilotBundle\PathResolver\PathResolverInterface;
use Pimcore\Model\Asset;
use Pimcore\Model\DataObject\AbstractObject;

class DatabasePathResolver implements PathResolverInterface
{
    public function __construct(private readonly \Doctrine\DBAL\Connection $db) {}

    public function resolve(
        AbstractObject $object,
        Asset $asset,
        Rule $rule,
        ?string $locale = null,
    ): string {
        $path = $this->db->fetchOne(
            'SELECT target_path FROM asset_path_mappings WHERE class_name = ? AND rule_name = ?',
            [$object->getClassName(), $rule->name]
        );
        return $path ?: '/Fallback/' . $object->getKey();
    }
}
```

```
services:
    Oronts\AssetPilotBundle\PathResolver\PathResolverInterface:
        alias: App\AssetPilot\PathResolver\DatabasePathResolver
```

### Custom Condition Evaluator

[](#custom-condition-evaluator)

Replace the ExpressionLanguage evaluator with your own logic. Implement `ConditionEvaluatorInterface` and override the service alias.

```
namespace App\AssetPilot\Condition;

use Oronts\AssetPilotBundle\Condition\ConditionEvaluatorInterface;
use Oronts\AssetPilotBundle\Model\Rule;
use Pimcore\Model\Asset;
use Pimcore\Model\DataObject\AbstractObject;

class WorkflowConditionEvaluator implements ConditionEvaluatorInterface
{
    public function evaluate(AbstractObject $object, Asset $asset, Rule $rule): bool
    {
        if ($rule->condition === null) {
            return true;
        }

        // Only proceed if the object's workflow state matches the condition
        return $object->getProperty('workflow_state') === $rule->condition;
    }
}
```

```
services:
    Oronts\AssetPilotBundle\Condition\ConditionEvaluatorInterface:
        alias: App\AssetPilot\Condition\WorkflowConditionEvaluator
```

### Custom Naming Strategy

[](#custom-naming-strategy)

Control how filenames are generated and collisions are resolved. Implement `NamingStrategyInterface` and override the service alias.

```
namespace App\AssetPilot\Naming;

use Oronts\AssetPilotBundle\Naming\NamingStrategyInterface;
use Pimcore\Model\Asset;

class HashNamingStrategy implements NamingStrategyInterface
{
    public function generateName(Asset $asset, string $targetPath): string
    {
        $ext = pathinfo($asset->getFilename(), PATHINFO_EXTENSION);
        $hash = substr(md5($asset->getFilename() . time()), 0, 8);
        return $hash . '.' . $ext;
    }
}
```

```
services:
    Oronts\AssetPilotBundle\Naming\NamingStrategyInterface:
        alias: App\AssetPilot\Naming\HashNamingStrategy
```

### Events

[](#events)

Subscribe to asset move events for custom logic:

```
namespace App\EventListener;

use Oronts\AssetPilotBundle\Event\AssetMoveEvent;
use Oronts\AssetPilotBundle\Event\AssetPilotEvents;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(event: AssetPilotEvents::PRE_MOVE)]
class AssetMoveListener
{
    public function __invoke(AssetMoveEvent $event): void
    {
        // Cancel moves to restricted paths
        if (str_starts_with($event->targetPath, '/Protected/')) {
            $event->cancel();
            return;
        }

        // Access event data: $event->asset, $event->object, $event->rule, $event->triggerType
    }
}
```

Available events:

EventConstantDescription`oronts_asset_pilot.pre_move``AssetPilotEvents::PRE_MOVE`Before move, cancellable`oronts_asset_pilot.post_move``AssetPilotEvents::POST_MOVE`After successful move`oronts_asset_pilot.move_failed``AssetPilotEvents::MOVE_FAILED`After failed move`oronts_asset_pilot.bulk_started``AssetPilotEvents::BULK_STARTED`Bulk operation started`oronts_asset_pilot.bulk_completed``AssetPilotEvents::BULK_COMPLETED`Bulk operation finished---

Database Schema
---------------

[](#database-schema)

The installer creates a single table:

```
CREATE TABLE asset_pilot_audit_log (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    asset_id    INT          NOT NULL,
    asset_path_from VARCHAR(500) NOT NULL,
    asset_path_to   VARCHAR(500) NOT NULL,
    object_id   INT          NOT NULL,
    object_class VARCHAR(255) NOT NULL,
    rule_name   VARCHAR(255) NOT NULL,
    trigger_type VARCHAR(50) NOT NULL,
    status      VARCHAR(50)  NOT NULL,
    error_message TEXT        NULL,
    duration_ms INT          NULL,
    user_id     INT          NULL,
    created_at  DATETIME     NOT NULL,

    INDEX idx_audit_asset_id   (asset_id),
    INDEX idx_audit_object_id  (object_id),
    INDEX idx_audit_rule_name  (rule_name),
    INDEX idx_audit_status     (status),
    INDEX idx_audit_created_at (created_at)
);
```

---

Testing
-------

[](#testing)

The bundle ships with 133 PHPUnit tests covering all core components.

```
cd bundles/asset-pilot-bundle
composer install
vendor/bin/phpunit
```

```
tests/
├── Unit/
│   ├── Condition/      ExpressionConditionEvaluator (15 tests)
│   ├── Engine/         RuleEngine (15 tests)
│   ├── Enum/           MoveStrategy, OperationStatus, TriggerType (5 tests)
│   ├── Event/          AssetMoveEvent, AssetPilotEvents (5 tests)
│   ├── EventListener/  DataObjectSaveListener (10 tests)
│   ├── Filter/         Type, Size, Extension, Composite filters (21 tests)
│   ├── Message/        OrganizeAssetsMessage, BulkOrganizeMessage (5 tests)
│   ├── MessageHandler/ BulkOrganizeHandler (4 tests)
│   ├── Model/          Rule, RuleMatch, MoveOperation, OperationResult, AssetFieldInfo (19 tests)
│   ├── Service/        LoopGuard (11 tests)
│   └── Strategy/       Always, FirstAssignment, Callback, Resolver (23 tests)
└── bootstrap.php

```

---

Requirements
------------

[](#requirements)

DependencyVersionPHP&gt;= 8.4Pimcore^12.0Symfony Expression Language^7.0Symfony Messenger^7.0Symfony Lock^7.0---

License
-------

[](#license)

This project is licensed under the [GNU Affero General Public License v3.0](LICENSE) (AGPL-3.0), the same license used by Pimcore itself.

You are free to use, modify, and distribute this bundle in both private and commercial projects. If you modify the source code and distribute it or run it as a service, you must make your modifications available under the same license.

---

Consulting &amp; Custom Development
-----------------------------------

[](#consulting--custom-development)

 [ ![Oronts](https://camo.githubusercontent.com/2cb3b0f79aec2adcbc15af45b590cb79bb44f244585a03d3eebb7130ec48c45e/68747470733a2f2f6f726f6e74732e636f6d2f5f6e6578742f696d6167653f75726c3d253246696d616765732532466c6f676f2532464c6f676f2d77686974652e706e6726773d32353626713d3735) ](https://oronts.com)

**Oronts** provides custom development and integration services:

- Pimcore bundle development and customization
- PIM/DAM implementation and architecture
- Asset workflow automation
- E-commerce platform implementation

**Contact:**  | [oronts.com](https://oronts.com)

---

**Author:** [Oronts](https://oronts.com) - AI-powered automation, e-commerce platforms, cloud infrastructure.

**Contributors:** Refaat Al Ktifan ()

###  Health Score

38

—

LowBetter than 83% of packages

Maintenance81

Actively maintained with recent releases

Popularity5

Limited adoption so far

Community7

Small or concentrated contributor base

Maturity51

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

101d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/84b6aaf8bd84ae14b9516d0b8565f9b303ebaadeb704db05edc168bc0937b107?d=identicon)[Oronts](/maintainers/Oronts)

---

Top Contributors

[![Refaat-alktifan](https://avatars.githubusercontent.com/u/23056891?v=4)](https://github.com/Refaat-alktifan "Refaat-alktifan (23 commits)")

---

Tags

asset-managementbundledamphppimcorepimcore-12pimcore-bundlepluginreactrest-apisymfonyautomationassetspimcoreorganizationpimcore-12studio-ui

###  Code Quality

TestsPHPUnit

Code StylePHP CS Fixer

### Embed Badge

![Health badge](/badges/oronts-asset-pilot-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/oronts-asset-pilot-bundle/health.svg)](https://phpackages.com/packages/oronts-asset-pilot-bundle)
```

###  Alternatives

[sulu/sulu

Core framework that implements the functionality of the Sulu content management system

1.3k1.4M196](/packages/sulu-sulu)[rcsofttech/audit-trail-bundle

Enterprise-grade, high-performance Symfony audit trail bundle. Automatically track Doctrine entity changes with split-phase architecture, multiple transports (HTTP, Queue, Doctrine), and sensitive data masking.

1155.2k](/packages/rcsofttech-audit-trail-bundle)[open-dxp/opendxp

Content &amp; Product Management Framework (CMS/PIM)

9317.2k55](/packages/open-dxp-opendxp)[in2code/lux

Living User eXperience - LUX - the Marketing Automation tool for TYPO3.

2156.6k1](/packages/in2code-lux)[chameleon-system/chameleon-base

The Chameleon System core.

1027.9k4](/packages/chameleon-system-chameleon-base)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
