Intelligent Rule-Based Asset Organization for Pimcore 12
Features • Installation • Configuration • Examples • Path Templates • Expression Language • Commands • REST API • Permissions • Studio UI • Extending • Testing
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.
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 (>90 days), probably unused (30-90 days), recently uploaded (<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.
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
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
composer require oronts/asset-pilot-bundleAdd to config/bundles.php:
return [
// ...
Oronts\AssetPilotBundle\OrontsAssetPilotBundle::class => ['all' => true],
];bin/console pimcore:bundle:install OrontsAssetPilotBundleThis creates:
- The
asset_pilot_audit_logtable with indexes onasset_id,object_id,rule_name,status, andcreated_at - Three Pimcore permissions:
asset_pilot_view,asset_pilot_operate,asset_pilot_admin
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# config/packages/lock.yaml
framework:
lock: 'redis://%env(REDIS_HOST)%'bin/console assets:install
bin/console cache:clearCreate 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| Key | Type | Default | Description |
|---|---|---|---|
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 |
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
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 |
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/
└── ...
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: 80Resulting folder structure:
/Catalog/
├── Electronics/
│ ├── ART-10042/
│ │ ├── Images/
│ │ └── Docs/
│ │ ├── en/
│ │ └── de/
│ └── ART-10043/
│ └── ...
├── Furniture/
│ └── ...
└── Uncategorized/
└── ...
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: 1Use 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]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;
}
}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: 10Lock specific folders from automation and configure the lock property name.
oronts_asset_pilot:
protection:
exclude_folders:
- /Protected/
- /Manual/
- /Brand-Assets/
lock_property: asset_pilot_lockedAssets 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.
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: 90Combine 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 MBTarget paths use Twig syntax. The resolver provides these context variables:
| Variable | Type | Description |
|---|---|---|
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 name |
You 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.
| Filter | Usage | Description |
|---|---|---|
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 |
| Function | Usage | Description |
|---|---|---|
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 |
# 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") }}'Rule conditions use Symfony ExpressionLanguage. Three variables are available: object, asset, and rule.
| Function | Returns | Example |
|---|---|---|
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/#") |
# 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)'# 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 all configured rules
bin/console asset-pilot:validate-configChecks 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 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=productImagesOutput 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.
# Show configured rules and statistics
bin/console asset-pilot:status
# JSON output for scripting
bin/console asset-pilot:status --format=json# 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# 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-runAll 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=42For Kubernetes/Docker environments, use CronJob resources or container-level cron scheduling.
Asset Pilot registers three permissions in Pimcore's native permission system during installation. Assign them to user roles via Settings > Users / Roles > Permissions.
| Permission | Key | Description |
|---|---|---|
| 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
}All endpoints are prefixed with /pimcore-studio/api/asset-pilot. Requires Pimcore Studio authentication. Permissions are enforced on every endpoint (see Permissions).
| Method | Endpoint | Permission | Description |
|---|---|---|---|
GET |
/dashboard |
View | Dashboard statistics |
GET |
/dashboard/class-stats |
View | Per-class breakdown |
GET |
/permissions |
— | Current user's permission set |
| Method | Endpoint | Permission | Description |
|---|---|---|---|
POST |
/organize |
Operate | Organize a single object |
POST |
/organize/preview |
View | Dry-run preview |
POST |
/organize/explain |
View | Detailed rule evaluation per asset |
POST |
/organize/bulk |
Operate | Bulk organize by class or IDs |
POST |
/operations/bulk-preview |
View | Paginated bulk preview |
GET |
/operations/status |
View | Operation statistics |
{
"objectId": 42,
"dryRun": false,
"async": true
}{
"className": "Product",
"async": true,
"batchSize": 50
}Or with explicit IDs:
{
"objectIds": [1, 2, 3, 4, 5],
"async": true,
"batchSize": 50
}{
"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
}]
}| Method | Endpoint | Permission | Description |
|---|---|---|---|
GET |
/rules |
View | List all configured rules |
GET |
/rules/{name} |
View | Rule details with statistics |
GET |
/rules/{name}/preview?objectId=42 |
View | Preview rule against an object |
| Method | Endpoint | Permission | Description |
|---|---|---|---|
GET |
/assets/search |
View | Search assets (params: q, type, folder, objectId, page, limit, sort, order) |
GET |
/assets/by-object/{objectId} |
View | Assets linked to a DataObject via dependencies (params: page, limit, type) |
GET |
/assets/tags |
View | List all available Pimcore tags |
GET |
/assets/{id}/tags |
View | Get tags for a specific asset |
POST |
/assets/{id}/lock |
Operate | Lock asset from organization |
DELETE |
/assets/{id}/lock |
Operate | Unlock asset |
POST |
/assets/bulk-tag |
Operate | Bulk assign tags to assets |
POST |
/assets/bulk-property |
Operate | Bulk set custom properties |
| Parameter | Type | Description |
|---|---|---|
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 |
{
"assetIds": [1, 2, 3],
"tagIds": [10, 20],
"replace": false
}Set replace: true to remove all existing tags before assigning new ones.
{
"assetIds": [1, 2, 3],
"name": "department",
"type": "text",
"data": "Marketing"
}Supported types: text, bool, select.
| Method | Endpoint | Permission | Description |
|---|---|---|---|
GET |
/unused-assets |
View | List unused assets with confidence scoring |
GET |
/unused-assets/stats |
View | Unused asset statistics by type |
POST |
/unused-assets/bulk-delete |
Operate | Delete unused assets |
POST |
/unused-assets/bulk-move |
Operate | Move unused assets to folder |
| Parameter | Type | Description |
|---|---|---|
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 |
| Level | Criteria | Recommended Action |
|---|---|---|
definitely_unused |
No references, last modified >90 days ago | Safe to delete |
probably_unused |
No references, last modified 30-90 days ago | Review before deleting |
recently_uploaded |
No references, last modified <30 days ago | Wait — may be in use soon |
historically_used |
No references, but has audit history of past moves | Investigate before deleting |
protected |
Has asset_pilot_locked property |
Excluded from cleanup |
| Method | Endpoint | Permission | Description |
|---|---|---|---|
GET |
/audit |
View | Paginated audit entries (params: page, limit, class, status, ruleName, sort, order) |
GET |
/audit/stats |
View | Audit statistics |
GET |
/audit/export |
View | Export as CSV (params: class, status, ruleName) |
GET |
/audit/by-rule/{ruleName}/assets |
View | Distinct assets moved by a rule (params: page, limit, since, class) |
POST |
/audit/{id}/revert |
Admin | Revert a completed operation |
| Parameter | Type | Description |
|---|---|---|
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) |
Asset Pilot integrates into Pimcore Studio as a Module Federation remote. The UI provides six tabs:
| Tab | Description |
|---|---|
| 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. |
Unused assets display color-coded confidence badges:
| Badge | Color | Meaning |
|---|---|---|
| Definitely Unused | Green | Safe to clean up (>90 days, no references) |
| Probably Unused | Yellow | Review recommended (30-90 days) |
| Recently Uploaded | Red | Wait before action (<30 days) |
| Historically Used | Orange | Was previously organized — investigate |
| Protected | Gray | Locked asset, excluded from cleanup |
The Studio UI ships with English and German translations. All UI strings use the asset-pilot.* i18n namespace.
Asset Pilot uses a multi-layer approach to prevent duplicate processing:
| Layer | Mechanism | TTL | Purpose |
|---|---|---|---|
| Loop Guard | Redis cache (cache.app) |
60s | Prevents re-entry when asset moves trigger DataObject saves |
| Recently Moved | Redis cache | 300s (5 min) | Prevents async ping-pong for shared assets between objects |
| Dispatch Dedup | Redis cache | 10s | Prevents 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/60s | Transport-level deduplication prevents the same message from being processed twice |
The DeduplicateStamp TTLs:
- 30 seconds for single-object organize messages (
asset_pilot_organize_{objectId}) - 60 seconds for bulk organize messages (
asset_pilot_bulk_{batchHash})
Asset Pilot is built around interfaces. Swap out any component by implementing the interface and registering it as a service.
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' }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\BusinessHoursStrategyReplace 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\DatabasePathResolverReplace 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\WorkflowConditionEvaluatorControl 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\HashNamingStrategySubscribe 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:
| Event | Constant | Description |
|---|---|---|
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 |
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)
);The bundle ships with 133 PHPUnit tests covering all core components.
cd bundles/asset-pilot-bundle
composer install
vendor/bin/phpunittests/
├── 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
| Dependency | Version |
|---|---|
| PHP | >= 8.4 |
| Pimcore | ^12.0 |
| Symfony Expression Language | ^7.0 |
| Symfony Messenger | ^7.0 |
| Symfony Lock | ^7.0 |
This project is licensed under the GNU Affero General Public License v3.0 (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.
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: office@oronts.com | oronts.com
Author: Oronts - AI-powered automation, e-commerce platforms, cloud infrastructure.
Contributors: Refaat Al Ktifan (Refaat@alktifan.com)
