# EspoCRM Custom Actions - Implementierungsprinzip **Version:** 1.0 **Datum:** 24. Januar 2026 **Blueprint für:** Custom Button Actions mit Entity-Erstellung und Relationen --- ## 📋 Inhaltsverzeichnis 1. [Überblick](#überblick) 2. [Architektur-Prinzipien](#architektur-prinzipien) 3. [Implementierungs-Schritte](#implementierungs-schritte) 4. [Code-Template](#code-template) 5. [Best Practices](#best-practices) 6. [Sicherheit](#sicherheit) 7. [Testing & Debugging](#testing--debugging) 8. [Beispiel-Implementierung](#beispiel-implementierung) --- ## Überblick Dieses Dokument beschreibt das standardisierte Vorgehen zur Implementierung von Custom Button Actions in EspoCRM, die: - Von einer Entity-Detailansicht ausgelöst werden - Eine neue verwandte Entity erstellen - Automatisch Daten und Relationen übertragen - ACL-konform und transaktionssicher arbeiten **Basis-Beispiel:** Button "Räumungsklage einleiten" in Mietobjekt → Erstellt CVmhRumungsklage mit allen Relationen --- ## Architektur-Prinzipien ### 1. Drei-Schichten-Architektur ``` ┌─────────────────────────────────────────────────────┐ │ FRONTEND (client/custom/) │ │ • JavaScript Handler (Action Trigger) │ │ • Button Definition (clientDefs) │ │ • i18n Translations │ └────────────────┬────────────────────────────────────┘ │ AJAX POST Request ┌────────────────▼────────────────────────────────────┐ │ CONTROLLER (custom/Espo/Custom/Controllers/) │ │ • Request Validation │ │ • Service Call │ │ • Response Formatting │ └────────────────┬────────────────────────────────────┘ │ Method Call ┌────────────────▼────────────────────────────────────┐ │ SERVICE (custom/Espo/Custom/Services/) │ │ • ACL Security Checks │ │ • Business Logic │ │ • Transaction Management │ │ • Entity Operations │ └─────────────────────────────────────────────────────┘ ``` ### 2. Separation of Concerns | Schicht | Verantwortlichkeit | Nicht erlaubt | |---------|-------------------|---------------| | **Frontend** | User Interaction, Confirmation, Navigation | Business Logic, Direct DB Access | | **Controller** | HTTP Request/Response Handling | Business Logic, Direct Entity Manipulation | | **Service** | Business Logic, ACL, Transactions, Entity Operations | HTTP Handling, View Logic | --- ## Implementierungs-Schritte ### Schritt 1: Backend Controller erstellen **Datei:** `custom/Espo/Custom/Controllers/[EntityName].php` ```php getParsedBody(); $id = $data->id ?? null; if (!$id) { throw new BadRequest('No Entity ID provided'); } $service = $this->getRecordService(); $result = $service->yourAction($id); return $result; } } ``` **Wichtig:** - Methode MUSS `postAction[Name]` heißen - Parameter ist `Request $request` - Return Type ist `array` - Extends `\Espo\Core\Templates\Controllers\Base` --- ### Schritt 2: Backend Service erstellen **Datei:** `custom/Espo/Custom/Services/[EntityName].php` ```php entityManager->getEntity('CYourEntity', $sourceId); if (!$source) { throw new NotFound('Entity not found'); } // 2. SECURITY: ACL-Checks if (!$this->acl->check($source, 'read')) { throw new Forbidden('No read access to source entity'); } if (!$this->acl->checkScope('CTargetEntity', 'create')) { throw new Forbidden('No create access to target entity'); } // 3. TRANSACTION: Start $this->entityManager->getTransactionManager()->start(); try { // 4. ERSTELLEN: Target Entity $data = new \stdClass(); $data->name = 'Generated from ' . $source->get('name'); // Optional: User und Teams übernehmen if ($source->get('assignedUserId')) { $data->assignedUserId = $source->get('assignedUserId'); } $teamsIds = $source->getLinkMultipleIdList('teams'); if (!empty($teamsIds)) { $data->teamsIds = $teamsIds; } $target = $this->entityManager->createEntity('CTargetEntity', (array)$data); if (!$target) { throw new \RuntimeException('Failed to create target entity'); } // 5. RELATIONEN: Verknüpfen $targetRepo = $this->entityManager->getRepository('CTargetEntity'); // Source → Target $targetRepo ->getRelation($target, 'sources') ->relate($source); // Related Entities übertragen $relatedEntities = $this->entityManager ->getRepository('CYourEntity') ->getRelation($source, 'relatedThings') ->find(); foreach ($relatedEntities as $related) { $targetRepo ->getRelation($target, 'relatedThings') ->relate($related); } // 6. COMMIT: Transaktion abschließen $this->entityManager->getTransactionManager()->commit(); // 7. RETURN: ID und Name für Frontend return [ 'id' => $target->getId(), 'name' => $target->get('name') ]; } catch (\Exception $e) { // ROLLBACK: Bei jedem Fehler $this->entityManager->getTransactionManager()->rollback(); throw $e; } } } ``` **Wichtig:** - Extends `\Espo\Services\Record` - Immer Transactions verwenden - Alle Exceptions im catch-Block fangen - Rollback bei Fehlern --- ### Schritt 3: Button im Frontend konfigurieren **Datei:** `custom/Espo/Custom/Resources/metadata/clientDefs/[EntityName].json` ```json { "menu": { "detail": { "buttons": [ { "name": "initiateYourAction", "label": "Your Action Button", "handler": "custom:handlers/your-entity/your-action", "iconHtml": "", "style": "danger", "acl": "edit" } ] } } } ``` **Button-Optionen:** - `name`: Eindeutiger Identifier - `label`: Translation-Key (siehe i18n) - `handler`: Pfad zum JavaScript Handler - `iconHtml`: FontAwesome Icon oder HTML - `style`: `default`, `success`, `danger`, `warning`, `info` - `acl`: `read`, `edit`, `delete` (benötigte Permission) --- ### Schritt 4: JavaScript Handler implementieren **Datei:** `client/custom/src/handlers/your-entity/your-action.js` ```javascript define('custom:handlers/your-entity/your-action', [], function () { class YourActionHandler { constructor(view) { this.view = view; } /** * Setup-Methode wird automatisch aufgerufen */ initYourAction() { this.view.listenTo( this.view, 'after:render', () => this.controlButtonVisibility() ); } /** * Button-Sichtbarkeit steuern (optional) */ controlButtonVisibility() { const model = this.view.model; // Beispiel: Button nur bei bestimmten Bedingungen zeigen if (!model.get('someField')) { this.view.hideHeaderActionItem('initiateYourAction'); } else { this.view.showHeaderActionItem('initiateYourAction'); } } /** * Action-Handler (wird bei Button-Click aufgerufen) */ actionInitiateYourAction() { const model = this.view.model; // 1. CONFIRMATION: User bestätigen lassen this.view.confirm( this.view.translate('confirmationMessage', 'messages', 'CYourEntity'), () => { this.initiateAction(model.id); } ); } /** * AJAX Request zum Backend */ initiateAction(entityId) { Espo.Ui.notify(this.view.translate('pleaseWait', 'messages')); Espo.Ajax.postRequest('CYourEntity/action/yourAction', { id: entityId }) .then(response => { Espo.Ui.success( this.view.translate('successMessage', 'messages', 'CYourEntity') ); // Navigation zur erstellten Entity this.view.getRouter().navigate( '#CTargetEntity/view/' + response.id, {trigger: true} ); }) .catch(xhr => { console.error('Action failed:', xhr); let errorMessage = this.view.translate('errorMessage', 'messages', 'CYourEntity'); if (xhr.status === 403) { errorMessage = this.view.translate('Access denied', 'messages'); } else if (xhr.status === 404) { errorMessage = this.view.translate('Not found', 'messages'); } Espo.Ui.error(errorMessage); }); } } return YourActionHandler; }); ``` **Wichtig:** - Class-Name muss mit Filename übereinstimmen (CamelCase) - Methode `init[ButtonName]()` für Setup - Methode `action[ButtonName]()` für Click-Handler - Immer Confirmation Dialog für kritische Actions - Error Handling mit spezifischen HTTP-Status-Codes --- ### Schritt 5: Übersetzungen hinzufügen **Datei:** `custom/Espo/Custom/Resources/i18n/de_DE/[EntityName].json` ```json { "labels": { "Your Action Button": "Ihre Aktion" }, "messages": { "confirmationMessage": "Möchten Sie diese Aktion wirklich ausführen?", "successMessage": "Die Aktion wurde erfolgreich ausgeführt", "errorMessage": "Fehler beim Ausführen der Aktion" } } ``` **Datei:** `custom/Espo/Custom/Resources/i18n/en_US/[EntityName].json` ```json { "labels": { "Your Action Button": "Your Action" }, "messages": { "confirmationMessage": "Do you really want to execute this action?", "successMessage": "Action executed successfully", "errorMessage": "Error executing action" } } ``` --- ### Schritt 6: Validierung & Rebuild ```bash # Im Root-Verzeichnis von EspoCRM python3 custom/scripts/validate_and_rebuild.py ``` **Prüfungen:** - ✅ JSON-Syntax aller Dateien - ✅ PHP-Syntax (Service, Controller) - ✅ JavaScript-Syntax (Handler) - ✅ Relationship-Konsistenz - ✅ Automatischer Cache-Clear & Rebuild --- ## Best Practices ### 1. Naming Conventions | Element | Pattern | Beispiel | |---------|---------|----------| | Controller Action | `postAction[Name]` | `postActionInitiateEviction` | | Service Method | `camelCase` | `initiateEviction` | | Button Name | `camelCase` | `initiateEviction` | | JS Handler File | `kebab-case.js` | `eviction-action.js` | | JS Class Name | `PascalCase` | `EvictionActionHandler` | | JS Init Method | `init[ButtonName]` | `initInitiateEviction` | | JS Action Method | `action[ButtonName]` | `actionInitiateEviction` | ### 2. Transaction Management **Immer Transactions verwenden** bei: - Entity-Erstellung - Mehreren Relationierungen - Datenänderungen über mehrere Entities ```php // ✅ RICHTIG $this->entityManager->getTransactionManager()->start(); try { // ... operations $this->entityManager->getTransactionManager()->commit(); } catch (\Exception $e) { $this->entityManager->getTransactionManager()->rollback(); throw $e; } // ❌ FALSCH - Keine Transaction $entity = $this->entityManager->createEntity(...); // Bei Fehler in nächstem Schritt: Inkonsistente Daten! $this->entityManager->getRepository(...)->relate(...); ``` ### 3. Repository Caching ```php // ✅ RICHTIG - Repository nur 1× holen $targetRepo = $this->entityManager->getRepository('Target'); foreach ($items as $item) { $targetRepo->getRelation($target, 'items')->relate($item); } // ❌ FALSCH - Repository in Schleife holen foreach ($items as $item) { $this->entityManager->getRepository('Target') ->getRelation($target, 'items') ->relate($item); } ``` ### 4. Error Messages ```php // ✅ RICHTIG - Spezifische Exceptions if (!$source) { throw new NotFound('Mietobjekt not found'); } if (!$this->acl->check($source, 'read')) { throw new Forbidden('No read access to Mietobjekt'); } // ❌ FALSCH - Generische Fehler if (!$source) { throw new \Exception('Error'); } ``` ### 5. Null Checks ```php // ✅ RICHTIG $entity = $this->entityManager->createEntity(...); if (!$entity) { throw new \RuntimeException('Failed to create entity'); } // ❌ FALSCH - Keine Prüfung $entity = $this->entityManager->createEntity(...); $entity->getId(); // Kann zu Fehler führen! ``` --- ## Sicherheit ### 1. ACL-Checks (Access Control Lists) **Minimum:** Immer beide Checks durchführen: ```php // Check 1: Kann User Source-Entity lesen? if (!$this->acl->check($sourceEntity, 'read')) { throw new Forbidden('No read access'); } // Check 2: Kann User Target-Entity erstellen? if (!$this->acl->checkScope('TargetEntity', 'create')) { throw new Forbidden('No create access'); } ``` **Optional:** Zusätzliche Checks je nach Business-Logik: ```php // Check 3: Kann User Source-Entity bearbeiten? if (!$this->acl->check($sourceEntity, 'edit')) { throw new Forbidden('No edit access'); } // Check 4: Ownership-Check if ($sourceEntity->get('assignedUserId') !== $this->user->getId()) { throw new Forbidden('Not the owner'); } ``` ### 2. Input Validation ```php // ✅ RICHTIG - ID validieren $id = $data->id ?? null; if (!$id || !is_string($id)) { throw new BadRequest('Invalid ID provided'); } // Optional: Format prüfen if (!preg_match('/^[a-f0-9]{17}$/', $id)) { throw new BadRequest('Invalid ID format'); } ``` ### 3. SQL Injection Prevention **Automatisch geschützt** durch: - ✅ EntityManager ORM (immer verwenden!) - ✅ Repository Pattern - ✅ Prepared Statements **Niemals:** - ❌ Direct SQL Queries - ❌ String Concatenation in Queries - ❌ `$pdo->query()` direkt verwenden ### 4. XSS Prevention **Automatisch geschützt** durch: - ✅ EspoCRM Template Engine - ✅ Automatic HTML Escaping - ✅ Content Security Policy Headers **Im Frontend:** ```javascript // ✅ RICHTIG - translate() escaped automatisch this.view.translate('message', 'messages', 'Entity'); // ❌ FALSCH - Rohe HTML-Injection $('.container').html('
' + userInput + '
'); ``` ### 5. CSRF Protection **Automatisch geschützt** durch: - ✅ EspoCRM API Token System - ✅ Espo.Ajax verwendet automatisch CSRF-Token - ✅ Session-basierte Validierung **Kein manueller Code nötig!** --- ## Testing & Debugging ### 1. Entwickler-Workflow ```bash # 1. Code ändern vim custom/Espo/Custom/Services/YourEntity.php # 2. Validieren & Rebuild python3 custom/scripts/validate_and_rebuild.py # 3. Browser Hard-Refresh # Ctrl + Shift + R (Chrome/Firefox) # Cmd + Shift + R (Mac) # 4. Test durchführen # 5. Logs prüfen bei Fehlern tail -f data/logs/espo-$(date +%Y-%m-%d).log ``` ### 2. Häufige Fehler & Lösungen | Fehler | Ursache | Lösung | |--------|---------|--------| | `Error 500: Call to undefined method getAcl()` | Falsche ACL-Methode | Nutze `$this->acl` statt `$this->getAcl()` | | `Error 500: Entity does not have a relation 'xyz'` | Relation existiert nicht | Prüfe entityDefs, korrekter Relationname | | Button erscheint nicht | Falsche clientDefs-Struktur | Nutze `menu.detail.buttons` nicht `detailActionList` | | JavaScript-Fehler | Handler nicht gefunden | Prüfe Pfad und Class-Name, Cache leeren | | Transaction-Fehler | Verschachtelte Transactions | Nur eine Transaction auf Service-Level | ### 3. Debugging-Kommandos ```bash # Logs live verfolgen tail -f data/logs/espo-$(date +%Y-%m-%d).log # Letzten Fehler finden tail -n 200 data/logs/espo-$(date +%Y-%m-%d).log | grep -i "error" # PHP-Syntax prüfen find custom/Espo/Custom -name "*.php" -exec php -l {} \; # JSON-Syntax prüfen find custom/Espo/Custom/Resources/metadata -name "*.json" -exec python3 -m json.tool {} \; > /dev/null # Cache manuell löschen rm -rf data/cache/* ``` ### 4. Browser DevTools **Console (F12):** ```javascript // Current Model inspizieren console.log(this.model.attributes); // AJAX Request debuggen Espo.Ajax.postRequest('Entity/action/test', {id: 'xyz'}) .then(r => console.log('Success:', r)) .catch(e => console.error('Error:', e)); ``` --- ## Beispiel-Implementierung ### Use Case: "Räumungsklage einleiten" **Requirement:** - Button in Mietobjekt-Detailansicht - Erstellt CVmhRumungsklage - Verknüpft Mietverhältnisse, Vermieter (Kläger), Mieter (Beklagte) - Kopiert assignedUser und Teams ### Dateien-Struktur ``` custom/Espo/Custom/ ├── Controllers/ │ └── CMietobjekt.php # POST Action Endpoint ├── Services/ │ └── CMietobjekt.php # Business Logic └── Resources/ ├── metadata/clientDefs/ │ └── CMietobjekt.json # Button Definition └── i18n/ ├── de_DE/ │ └── CMietobjekt.json # Deutsche Übersetzungen └── en_US/ └── CMietobjekt.json # Englische Übersetzungen client/custom/src/handlers/ └── mietobjekt/ └── eviction-action.js # Frontend Handler ``` ### Vollständiger Code **1. Controller** ([CMietobjekt.php](custom/Espo/Custom/Controllers/CMietobjekt.php)): ```php getParsedBody(); $id = $data->id ?? null; if (!$id) { throw new BadRequest('No Mietobjekt ID provided'); } return $this->getRecordService()->initiateEviction($id); } } ``` **2. Service** ([CMietobjekt.php](custom/Espo/Custom/Services/CMietobjekt.php)): ```php entityManager->getEntity('CMietobjekt', $mietobjektId); if (!$mietobjekt) { throw new NotFound('Mietobjekt not found'); } // 2. ACL Checks if (!$this->acl->check($mietobjekt, 'read')) { throw new Forbidden('No read access to Mietobjekt'); } if (!$this->acl->checkScope('CVmhRumungsklage', 'create')) { throw new Forbidden('No create access to Räumungsklage'); } // 3. Start Transaction $this->entityManager->getTransactionManager()->start(); try { // 4. Create Räumungsklage $data = new \stdClass(); $data->name = 'Räumungsklage - ' . $mietobjekt->get('name'); if ($mietobjekt->get('assignedUserId')) { $data->assignedUserId = $mietobjekt->get('assignedUserId'); } $teamsIds = $mietobjekt->getLinkMultipleIdList('teams'); if (!empty($teamsIds)) { $data->teamsIds = $teamsIds; } $raeumungsklage = $this->entityManager->createEntity( 'CVmhRumungsklage', (array)$data ); if (!$raeumungsklage) { throw new \RuntimeException('Failed to create Räumungsklage'); } $repo = $this->entityManager->getRepository('CVmhRumungsklage'); // 5. Link Relations $repo->getRelation($raeumungsklage, 'mietobjekte') ->relate($mietobjekt); $mietverhaeltnisse = $this->entityManager ->getRepository('CMietobjekt') ->getRelation($mietobjekt, 'vmhMietverhltnises') ->find(); foreach ($mietverhaeltnisse as $mv) { $repo->getRelation($raeumungsklage, 'vmhMietverhltnises') ->relate($mv); // Link Vermieter as Kläger $vermieter = $this->entityManager ->getRepository('CVmhMietverhltnis') ->getRelation($mv, 'vmhbeteiligtevermieter') ->find(); foreach ($vermieter as $v) { $repo->getRelation($raeumungsklage, 'klaeger')->relate($v); } // Link Mieter as Beklagte $mieter = $this->entityManager ->getRepository('CVmhMietverhltnis') ->getRelation($mv, 'vmhbeteiligtemieter') ->find(); foreach ($mieter as $m) { $repo->getRelation($raeumungsklage, 'beklagte')->relate($m); } } // 6. Commit $this->entityManager->getTransactionManager()->commit(); return [ 'id' => $raeumungsklage->getId(), 'name' => $raeumungsklage->get('name') ]; } catch (\Exception $e) { $this->entityManager->getTransactionManager()->rollback(); throw $e; } } } ``` **3. Button Config** ([clientDefs/CMietobjekt.json](custom/Espo/Custom/Resources/metadata/clientDefs/CMietobjekt.json)): ```json { "menu": { "detail": { "buttons": [ { "name": "initiateEviction", "label": "Initiate Eviction", "handler": "custom:handlers/mietobjekt/eviction-action", "iconHtml": "", "style": "danger", "acl": "edit" } ] } } } ``` **4. JavaScript Handler** ([eviction-action.js](client/custom/src/handlers/mietobjekt/eviction-action.js)): ```javascript define('custom:handlers/mietobjekt/eviction-action', [], function () { class EvictionActionHandler { constructor(view) { this.view = view; } initInitiateEviction() { // Optional: Button-Logik nach Render } actionInitiateEviction() { this.view.confirm( this.view.translate('confirmEviction', 'messages', 'CMietobjekt'), () => this.initiateEviction(this.view.model.id) ); } initiateEviction(id) { Espo.Ui.notify(this.view.translate('pleaseWait', 'messages')); Espo.Ajax.postRequest('CMietobjekt/action/initiateEviction', { id }) .then(response => { Espo.Ui.success( this.view.translate('evictionCreated', 'messages', 'CMietobjekt') ); this.view.getRouter().navigate( '#CVmhRumungsklage/view/' + response.id, { trigger: true } ); }) .catch(xhr => { let msg = this.view.translate('evictionError', 'messages', 'CMietobjekt'); if (xhr.status === 403) { msg = this.view.translate('Access denied', 'messages'); } Espo.Ui.error(msg); }); } } return EvictionActionHandler; }); ``` **5. Translations** ([i18n/de_DE/CMietobjekt.json](custom/Espo/Custom/Resources/i18n/de_DE/CMietobjekt.json)): ```json { "labels": { "Initiate Eviction": "Räumungsklage einleiten" }, "messages": { "confirmEviction": "Möchten Sie wirklich eine Räumungsklage einleiten?", "evictionCreated": "Räumungsklage wurde erfolgreich erstellt", "evictionError": "Fehler beim Erstellen der Räumungsklage" } } ``` --- ## Checkliste für neue Implementierungen ### Vor dem Start - [ ] Requirements klar definiert (Welche Entity? Welche Relationen?) - [ ] Entity-Relationen in entityDefs geprüft - [ ] ACL-Requirements geklärt - [ ] Datenfluss dokumentiert ### Implementierung - [ ] Controller erstellt mit `postAction[Name]` Methode - [ ] Service erstellt mit Business-Logik - [ ] ACL-Checks: `check()` für Source + `checkScope()` für Target - [ ] Transaction: `start()` → `try/catch` → `commit()/rollback()` - [ ] Null-Check nach `createEntity()` - [ ] Repository-Caching bei Loops - [ ] Button in clientDefs konfiguriert (`menu.detail.buttons`) - [ ] JavaScript Handler erstellt - [ ] Init-Methode: `init[ButtonName]()` - [ ] Action-Methode: `action[ButtonName]()` - [ ] Confirmation Dialog implementiert - [ ] Error Handling mit HTTP-Status-Codes - [ ] Translations: Deutsch + Englisch - [ ] Labels + Messages vollständig ### Testing - [ ] Validierung erfolgreich: `validate_and_rebuild.py` - [ ] Browser Hard-Refresh durchgeführt - [ ] Button erscheint in Detailansicht - [ ] Confirmation Dialog funktioniert - [ ] Entity wird korrekt erstellt - [ ] Relationen sind korrekt verknüpft - [ ] Navigation zur neuen Entity funktioniert - [ ] ACL wird respektiert (Test mit eingeschränktem User) - [ ] Error Cases getestet (keine Permission, Entity nicht gefunden) - [ ] Logs geprüft: Keine Errors ### Dokumentation - [ ] Code-Kommentare hinzugefügt - [ ] PHPDoc vollständig - [ ] README aktualisiert (falls generische Änderungen) --- ## Support & Weiterentwicklung ### Erweiterungen des Templates **Optional implementierbar:** - Conditional Button Visibility (nur unter bestimmten Bedingungen) - Batch Operations (mehrere Entities gleichzeitig) - Background Jobs (lange Operationen asynchron) - Email-Benachrichtigungen nach Erstellung - PDF-Generierung - Webhook-Trigger - Audit Logging ### Performance-Optimierungen Bei großen Datenmengen (> 100 Relations): - Batch-Insert mit `saveEntity()` statt `createEntity()` - Eager Loading mit `with()` - Query Optimization mit `select()` und `where()` - Background Jobs für lange Operationen ### Nützliche Links - [EspoCRM Dokumentation](https://docs.espocrm.com) - [GitHub Repository](https://github.com/espocrm/espocrm) - [Community Forum](https://forum.espocrm.com) - [API Dokumentation](https://docs.espocrm.com/development/api/) --- ## Versionierung | Version | Datum | Änderungen | |---------|-------|------------| | 1.0 | 24.01.2026 | Initiale Version basierend auf Räumungsklage-Implementierung | --- **Erstellt von:** EspoCRM Development Team **Letzte Aktualisierung:** 24. Januar 2026 **Status:** Production Ready ✅