48 KiB
EspoCRM Best Practices & Entwicklungsrichtlinien
Version: 2.2
Datum: 10. März 2026
Zielgruppe: AI Code Agents & Entwickler
🔄 Letzte Änderungen (v2.2 - 10. März 2026)
Kritische Erkenntnisse:
- ✅ Service-Klassen sind PFLICHT: Neue Section über erforderliche Service-Klassen
- ✅ InjectableFactory-Fehler: Detaillierter Troubleshooting-Guide hinzugefügt
- ✅ validate_and_rebuild.py v2.0: Erweiterte Log-Prüfung und CRUD-Tests
- ✅ Real-World-Beispiel: CAICollection/CAdvowareAkten Fehlerfall dokumentiert
Tools:
- 🆕 Minimierter/Verbose Output-Modus
- 🆕 Log-Prüfung nach jedem API-Request
- 🆕 CRUD-Tests für alle Entities
- 🆕 KI-freundliches JSON-Feedback
📋 Inhaltsverzeichnis
- Projekt-Übersicht
- Architektur-Prinzipien
- Entity-Entwicklung
- Relationship-Patterns
- API-Entwicklung
- Hook-Entwicklung
- Workflow-Management
- Testing & Validierung
- Fehlerbehandlung
- Deployment-Prozess
- Troubleshooting
Projekt-Übersicht
System-Architektur
EspoCRM 9.3.2
├── PHP 8.2.30
├── MariaDB 12.2.2
├── Docker Container: espocrm, espocrm-db
└── Workspace: /var/lib/docker/volumes/vmh-espocrm_espocrm/_data
Verzeichnisstruktur
custom/
├── Espo/Custom/ # Backend-Code
│ ├── Controllers/ # REST API Endpoints
│ ├── Services/ # Business Logic
│ ├── Repositories/ # Data Access Layer
│ ├── Hooks/ # Entity Lifecycle Hooks
│ └── Resources/
│ ├── metadata/ # Entity & Field Definitionen
│ │ ├── entityDefs/ # Entity-Konfiguration
│ │ ├── clientDefs/ # Frontend-Konfiguration
│ │ ├── scopes/ # Entity-Scopes
│ │ └── formula/ # Formula Scripts
│ ├── layouts/ # UI-Layouts
│ └── i18n/ # Übersetzungen (de_DE, en_US)
├── scripts/ # Entwicklungs-Tools
│ ├── validate_and_rebuild.py # Haupt-Validierungs-Tool
│ ├── e2e_tests.py # End-to-End Tests
│ ├── ki_project_overview.py # Projekt-Analyse für AI
│ └── junctiontabletests/ # Junction Table Tests
├── docs/ # Dokumentation (NEU)
│ ├── ESPOCRM_BEST_PRACTICES.md # Dieses Dokument
│ ├── tools/ # Tool-Dokumentation
│ └── workflows/ # Workflow-Dokumentation
└── workflows/ # Workflow JSON-Definitions
client/custom/ # Frontend-Code
├── src/ # JavaScript Modules
├── css/ # Custom Styles
└── res/ # Resources
Custom Entities Übersicht
19 Custom Entities implementiert (Stand: März 2026):
| Entity | Beschreibung | Hooks | Typ |
|---|---|---|---|
CAdressen |
Adressen-Verwaltung | - | Base |
CAICollections |
AI-Dokumenten-Sammlungen | - | Base |
CAICollectionCDokumente |
Junction: Collections ↔ Dokumente | - | Junction |
CBankverbindungen |
Bankdaten (IBAN/BIC) | ✅ Validierung | Base |
CBeteiligte |
Beteiligte Personen | - | Base |
CCallQueues |
Call-Warteschlangen | - | Base |
CDokumente |
Dokumenten-Management | ✅ Hash-Berechnung | Base |
CKuendigung |
Kündigungen | - | Base |
CMietinkasso |
Mietinkasso-Fälle | - | Base |
CMietobjekt |
Mietobjekte | - | Base |
CPuls |
Posteingangs-System | ✅ Statistik | Base |
CPulsTeamZuordnung |
Puls-Team-Zuordnungen | - | Base |
CVMHBeteiligte |
VMH-spezifische Beteiligte | - | Base |
CVmhErstgespraech |
Erstgespräche | - | Base |
CVmhMietverhltnis |
Mietverhältnisse | - | Base |
CVmhRumungsklage |
Räumungsklagen | - | Base |
CVmhVermieter |
Vermieter | - | Base |
Standard-Entities erweitert:
Contact- Erweiterterte Kontakt-FelderCall- Custom Call-FelderUser- User-ErweiterungenMeeting- Meeting-ErweiterungenEmail- E-Mail-AnpassungenTask- Task-AnpassungenPhoneNumber- Telefonnummern-ErweiterungenTeam- Team-AnpassungenBpmnUserTask- Workflow-Task-Erweiterungen
Implementierte Hooks:
- CBankverbindungen/BankdatenValidation - IBAN/BIC-Validierung mit Modulo-97
- CDokumente/CDokumente - MD5/SHA256-Hash-Berechnung für Uploads
- CPuls/UpdateTeamStats - Automatische Statistik-Berechnung
Architektur-Prinzipien
1. Separation of Concerns
EspoCRM = Data Layer
- Speichert Entities
- Stellt UI bereit
- Validiert Daten
- Bietet REST API
Middleware = Business Logic
- KI-Analyse
- Team-Zuweisung
- Komplexe Workflows
- Externe Integrationen
2. Drei-Schichten-Architektur
┌─────────────────────────────────────────┐
│ FRONTEND (clientDefs, Layouts) │
│ • User Interface │
│ • JavaScript Actions │
└────────────────┬────────────────────────┘
│ AJAX/REST
┌────────────────▼────────────────────────┐
│ CONTROLLER (Controllers/) │
│ • Request Validation │
│ • ACL Checks │
└────────────────┬────────────────────────┘
│ Service Call
┌────────────────▼────────────────────────┐
│ SERVICE (Services/) │
│ • Business Logic │
│ • Entity Manager │
└────────────────┬────────────────────────┘
│ Repository
┌────────────────▼────────────────────────┐
│ REPOSITORY (Repositories/) │
│ • Data Access │
│ • Relationships │
└─────────────────────────────────────────┘
3. Clean Code Principles
DO:
- ✅ Nutze sprechende Variablennamen
- ✅ Schreibe kleine, fokussierte Funktionen
- ✅ Kommentiere komplexe Business-Logik
- ✅ Verwende Type Hints (PHP 8.2+)
- ✅ Folge PSR-12 Coding Standard
DON'T:
- ❌ Keine komplexe Business-Logic in Hooks (nutze Services)
- ❌ Keine direkten SQL-Queries (nutze EntityManager)
- ❌ Keine hard-coded Werte (nutze Config)
- ❌ Keine redundanten Includes
- ❌ Keine ungenutzten Imports
Entity-Entwicklung
Entity-Naming Convention
Pattern: C{EntityName} für Custom Entities
Beispiele:
CMietobjekt- MietobjekteCVmhMietverhltnis- Mietverhältnisse (VMH = Vermieter Helden)CKuendigung- KündigungenCAICollections- AI Collections
Entity Definition Template
Datei: custom/Espo/Custom/Resources/metadata/entityDefs/{EntityName}.json
{
"fields": {
"name": {
"type": "varchar",
"required": true,
"maxLength": 255,
"trim": true,
"isCustom": true,
"tooltip": true
},
"status": {
"type": "enum",
"options": ["Neu", "In Bearbeitung", "Abgeschlossen"],
"default": "Neu",
"required": true,
"isCustom": true,
"style": {
"Neu": "primary",
"In Bearbeitung": "warning",
"Abgeschlossen": "success"
}
},
"description": {
"type": "text",
"rows": 10,
"isCustom": true,
"tooltip": true
},
"amount": {
"type": "currency",
"isCustom": true,
"audited": true
},
"dueDate": {
"type": "date",
"isCustom": true,
"audited": true
}
},
"links": {
"parent": {
"type": "belongsToParent",
"entityList": ["CVmhRumungsklage", "CMietinkasso"]
},
"createdBy": {
"type": "belongsTo",
"entity": "User"
},
"modifiedBy": {
"type": "belongsTo",
"entity": "User"
}
}
}
Scope Definition
Datei: custom/Espo/Custom/Resources/metadata/scopes/{EntityName}.json
{
"entity": true,
"type": "Base",
"module": "Custom",
"object": true,
"isCustom": true,
"tab": true,
"acl": true,
"stream": true,
"disabled": false,
"customizable": true,
"importable": true,
"notifications": true,
"calendar": false
}
Wichtige Flags:
tab: true- Zeigt Entity in Navigationacl: true- ACL-System aktivstream: true- Stream/Activity Feedcalendar: true- Für Entities mit Datum-Feldern
Service-Klassen (KRITISCH!)
⚠️ PFLICHT: Jede Custom Entity MUSS eine Service-Klasse haben!
Problem: Ohne Service-Klasse gibt EspoCRM beim Zugriff folgenden Fehler:
CRITICAL: InjectableFactory: Class 'Espo\Custom\Services\{EntityName}' does not exist.
Lösung: Erstelle für jede Entity eine Service-Klasse:
Datei: custom/Espo/Custom/Services/{EntityName}.php
<?php
namespace Espo\Custom\Services;
use Espo\Services\Record;
/**
* Service für {EntityName} Entity
*/
class {EntityName} extends Record
{
// Basis-Service-Funktionalität wird von Record geerbt
// Custom Business Logic kann hier hinzugefügt werden
}
Minimale Service-Klasse:
- Erbt von
\Espo\Services\Record - Muss im Namespace
Espo\Custom\Servicessein - Klassenname muss exakt dem Entity-Namen entsprechen
- Mindestens leerer Body erforderlich
Erweiterte Service mit Custom Logic:
<?php
namespace Espo\Custom\Services;
use Espo\Services\Record;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
class {EntityName} extends Record
{
/**
* Custom Business Logic Methode
*/
public function customAction(string $id, \stdClass $data): array
{
// ACL Check
if (!$this->getAcl()->checkEntityEdit($this->entityType)) {
throw new Forbidden();
}
// Load Entity
$entity = $this->getEntityManager()->getEntity($this->entityType, $id);
if (!$entity) {
throw new NotFound();
}
// Business Logic hier
$entity->set('status', 'Processed');
$this->getEntityManager()->saveEntity($entity);
return [
'success' => true,
'id' => $entity->getId()
];
}
}
Best Practice:
- ✅ Erstelle Service-Klasse SOFORT bei Entity-Erstellung
- ✅ Auch wenn initial leer, erstelle sie trotzdem
- ✅ Nutze Service für Business Logic statt Hooks
- ✅ Verwende Type Hints für bessere IDE-Unterstützung
- ✅ Dokumentiere Custom-Methoden mit DocBlocks
i18n (Internationalisierung)
KRITISCH: Immer BEIDE Sprachen pflegen!
Datei: custom/Espo/Custom/Resources/i18n/de_DE/{EntityName}.json
{
"labels": {
"Create {EntityName}": "{EntityName} erstellen",
"{EntityName}": "{EntityName}",
"name": "Name",
"status": "Status",
"description": "Beschreibung"
},
"fields": {
"name": "Name",
"status": "Status",
"description": "Beschreibung",
"amount": "Betrag",
"dueDate": "Fälligkeitsdatum"
},
"links": {
"parent": "Übergeordnet",
"relatedEntity": "Verknüpfte Entity"
},
"options": {
"status": {
"Neu": "Neu",
"In Bearbeitung": "In Bearbeitung",
"Abgeschlossen": "Abgeschlossen"
}
},
"tooltips": {
"name": "Eindeutiger Name des Datensatzes",
"description": "Detaillierte Beschreibung"
}
}
Datei: custom/Espo/Custom/Resources/i18n/en_US/{EntityName}.json
{
"labels": {
"Create {EntityName}": "Create {EntityName}",
"{EntityName}": "{EntityName}"
},
"fields": {
"name": "Name",
"status": "Status",
"description": "Description",
"amount": "Amount",
"dueDate": "Due Date"
},
"links": {
"parent": "Parent",
"relatedEntity": "Related Entity"
},
"options": {
"status": {
"Neu": "New",
"In Bearbeitung": "In Progress",
"Abgeschlossen": "Completed"
}
}
}
Relationship-Patterns
1. One-to-Many (hasMany / belongsTo)
Beispiel: Ein Mietobjekt hat viele Mietverhältnisse
Parent Entity (CMietobjekt):
{
"links": {
"mietverhltnisse": {
"type": "hasMany",
"entity": "CVmhMietverhltnis",
"foreign": "mietobjekt"
}
}
}
Child Entity (CVmhMietverhltnis):
{
"fields": {
"mietobjektId": {
"type": "varchar",
"len": 17
},
"mietobjektName": {
"type": "varchar"
}
},
"links": {
"mietobjekt": {
"type": "belongsTo",
"entity": "CMietobjekt",
"foreign": "mietverhltnisse"
}
}
}
2. Many-to-Many (hasMany mit relationName)
Beispiel: Dokumente ↔ AI Collections
Entity 1 (CDokumente):
{
"links": {
"cAICollections": {
"type": "hasMany",
"entity": "CAICollections",
"foreign": "cDokumente",
"relationName": "cAICollectionCDokumente"
}
}
}
Entity 2 (CAICollections):
{
"links": {
"cDokumente": {
"type": "hasMany",
"entity": "CDokumente",
"foreign": "cAICollections",
"relationName": "cAICollectionCDokumente"
}
}
}
Wichtig: relationName muss identisch sein!
3. Many-to-Many mit additionalColumns (Junction Entity)
Seit EspoCRM 6.0: Junction-Tabellen werden automatisch als Entities verfügbar!
Entity Definition:
{
"links": {
"cDokumente": {
"type": "hasMany",
"entity": "CDokumente",
"foreign": "cAICollections",
"relationName": "cAICollectionCDokumente",
"additionalColumns": {
"syncId": {
"type": "varchar",
"len": 255
}
}
}
}
}
Junction Entity (CAICollectionCDokumente):
entityDefs/CAICollectionCDokumente.json:
{
"fields": {
"id": {
"type": "id",
"dbType": "bigint",
"autoincrement": true
},
"cAICollections": {
"type": "link"
},
"cAICollectionsId": {
"type": "varchar",
"len": 17,
"index": true
},
"cDokumente": {
"type": "link"
},
"cDokumenteId": {
"type": "varchar",
"len": 17,
"index": true
},
"syncId": {
"type": "varchar",
"len": 255,
"isCustom": true
},
"deleted": {
"type": "bool",
"default": false
}
},
"links": {
"cAICollections": {
"type": "belongsTo",
"entity": "CAICollections"
},
"cDokumente": {
"type": "belongsTo",
"entity": "CDokumente"
}
}
}
scopes/CAICollectionCDokumente.json:
{
"entity": true,
"type": "Base",
"module": "Custom",
"object": true,
"isCustom": true,
"tab": false,
"acl": true,
"disabled": false
}
Controller & Service:
<?php
// Controllers/CAICollectionCDokumente.php
namespace Espo\Custom\Controllers;
use Espo\Core\Controllers\Record;
class CAICollectionCDokumente extends Record
{
// Erbt alle CRUD-Operationen
}
// Services/CAICollectionCDokumente.php
namespace Espo\Custom\Services;
use Espo\Services\Record;
class CAICollectionCDokumente extends Record
{
// Standard-Logik
}
API-Zugriff:
# Alle Junction-Einträge
GET /api/v1/CAICollectionCDokumente
# Filtern nach Dokument
GET /api/v1/CAICollectionCDokumente?where[0][type]=equals&where[0][attribute]=cDokumenteId&where[0][value]=doc123
# Neuen Eintrag erstellen
POST /api/v1/CAICollectionCDokumente
{
"cDokumenteId": "doc123",
"cAICollectionsId": "col456",
"syncId": "SYNC-2026-001"
}
WICHTIG: additionalColumns funktionieren NICHT über Standard-Relationship-Endpoints! Nur über Junction-Entity-API!
4. Parent Relationship (belongsToParent)
Beispiel: Dokument kann zu Räumungsklage ODER Mietinkasso gehören
{
"fields": {
"parentType": {
"type": "varchar"
},
"parentId": {
"type": "varchar"
},
"parentName": {
"type": "varchar"
}
},
"links": {
"parent": {
"type": "belongsToParent",
"entityList": ["CVmhRumungsklage", "CMietinkasso", "CKuendigung"]
}
}
}
API-Entwicklung
REST API Endpoints
Standard CRUD (automatisch verfügbar):
GET /api/v1/{EntityName} # List
GET /api/v1/{EntityName}/{id} # Read
POST /api/v1/{EntityName} # Create
PUT /api/v1/{EntityName}/{id} # Update
DELETE /api/v1/{EntityName}/{id} # Delete
Custom API Endpoint erstellen
1. Controller Action:
Datei: custom/Espo/Custom/Controllers/{EntityName}.php
<?php
namespace Espo\Custom\Controllers;
use Espo\Core\Controllers\Record;
use Espo\Core\Api\Request;
class CMyEntity extends Record
{
/**
* Custom Action: POST /api/v1/CMyEntity/action/doSomething
*/
public function postActionDoSomething(Request $request): array
{
$data = $request->getParsedBody();
$id = $data->id ?? null;
if (!$id) {
throw new BadRequest('ID is required');
}
$result = $this->getRecordService()->doSomething($id, $data);
return [
'success' => true,
'data' => $result
];
}
/**
* Custom GET Action: GET /api/v1/CMyEntity/{id}/customData
*/
public function getActionCustomData(Request $request): array
{
$id = $request->getRouteParam('id');
$data = $this->getRecordService()->getCustomData($id);
return [
'data' => $data
];
}
}
2. Service Logic:
Datei: custom/Espo/Custom/Services/{EntityName}.php
<?php
namespace Espo\Custom\Services;
use Espo\Services\Record;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
class CMyEntity extends Record
{
public function doSomething(string $id, \stdClass $data): array
{
// ACL Check
if (!$this->getAcl()->checkEntityEdit($this->entityType)) {
throw new Forbidden();
}
// Load Entity
$entity = $this->getEntityManager()->getEntity($this->entityType, $id);
if (!$entity) {
throw new NotFound();
}
// Business Logic
$entity->set('status', 'In Bearbeitung');
$this->getEntityManager()->saveEntity($entity);
// Return Result
return [
'id' => $entity->getId(),
'status' => $entity->get('status')
];
}
public function getCustomData(string $id): array
{
$entity = $this->getEntityManager()->getEntity($this->entityType, $id);
if (!$entity) {
throw new NotFound();
}
// Complex data aggregation
$relatedData = $this->getRelatedData($entity);
return [
'entity' => $entity->getValueMap(),
'related' => $relatedData
];
}
}
API Authentication
API Key Header:
curl -X GET "https://crm.example.com/api/v1/CMyEntity" \
-H "X-Api-Key: your-api-key-here"
Test API Keys:
marvin:e53def10eea27b92a6cd00f40a3e09a4dev-test:2b0747ca34d15032aa233ae043cc61bc
Hook-Entwicklung
Überblick
Hooks sind Event-Handler, die bei Entity-Lifecycle-Events ausgeführt werden. Sie ermöglichen automatische Validierung, Berechnung und Synchronisation ohne Frontend-Änderungen.
Verzeichnis: custom/Espo/Custom/Hooks/{EntityName}/
Hook-Typen (EspoCRM 9.x Interface-basiert):
| Interface | Trigger | Verwendung |
|---|---|---|
BeforeSave |
Vor dem Speichern | Validierung, Feld-Berechnung, Normalisierung |
AfterSave |
Nach dem Speichern | Notifications, externe API-Calls, Statistik-Updates |
BeforeRemove |
Vor dem Löschen | Validierung, Cascade-Prüfungen |
AfterRemove |
Nach dem Löschen | Cleanup, externe System-Updates |
AfterRelate |
Nach Relationship-Link | Statistik-Updates, Synchronisation |
AfterUnrelate |
Nach Relationship-Unlink | Statistik-Updates, Cleanup |
Hook-Pattern (EspoCRM 9.x)
Moderne Interface-basierte Hooks (PHP 8.2+):
<?php
namespace Espo\Custom\Hooks\{EntityName};
use Espo\ORM\Entity;
use Espo\ORM\Repository\Option\SaveOptions;
use Espo\Core\Hook\Hook\BeforeSave;
class MyHook implements BeforeSave
{
public function __construct(
private \Espo\ORM\EntityManager $entityManager,
private \Espo\Core\Utils\Config $config
) {}
public function beforeSave(Entity $entity, SaveOptions $options): void
{
// Hook-Logik hier
}
}
Legacy Hooks (EspoCRM < 7.0, noch unterstützt):
<?php
namespace Espo\Custom\Hooks\{EntityName};
use Espo\ORM\Entity;
class MyHook extends \Espo\Core\Hooks\Base
{
public function beforeSave(Entity $entity, array $options = [])
{
// Hook-Logik hier
}
}
Dependency Injection
Moderne Hooks nutzen Constructor Injection:
public function __construct(
private \Espo\ORM\EntityManager $entityManager,
private \Espo\Core\Utils\Config $config,
private \Espo\Core\Utils\Language $language,
private \Espo\Core\ServiceFactory $serviceFactory,
private \Espo\Core\Mail\EmailSender $emailSender
) {}
Verfügbare Services:
EntityManager- DatenbankzugriffConfig- System-KonfigurationLanguage- i18n-ÜbersetzungenServiceFactory- Service-InstanzenEmailSender- E-Mail-VersandAcl- ACL-PrüfungenUser- Aktueller Benutzer
Praxis-Beispiele aus dem Projekt
Beispiel 1: Daten-Validierung & Normalisierung (CBankverbindungen)
Datei: custom/Espo/Custom/Hooks/CBankverbindungen/BankdatenValidation.php
Use Case: IBAN/BIC-Validierung mit Modulo-97-Algorithmus
<?php
namespace Espo\Custom\Hooks\CBankverbindungen;
use Espo\ORM\Entity;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Utils\Language;
class BankdatenValidation
{
public function __construct(
private Language $language
) {}
public function beforeSave(Entity $entity, array $options): void
{
// IBAN-Normalisierung und Validierung
$iban = $entity->get('iban');
if ($iban !== null && $iban !== '') {
// Normalisieren: Leerzeichen entfernen, Großbuchstaben
$ibanClean = strtoupper(str_replace(' ', '', $iban));
$entity->set('iban', $ibanClean);
// Mathematische IBAN-Prüfung mit Modulo-97
if (!$this->validateIban($ibanClean)) {
$message = $this->language->translateLabel(
'invalidIbanChecksum',
'messages',
'CBankverbindungen'
);
throw new BadRequest($message);
}
}
// BIC-Normalisierung und Validierung
$bic = $entity->get('bic');
if ($bic !== null && $bic !== '') {
$bicClean = strtoupper(str_replace(' ', '', $bic));
$entity->set('bic', $bicClean);
// BIC-Format: 8 oder 11 Zeichen
$bicLength = strlen($bicClean);
if ($bicLength !== 8 && $bicLength !== 11) {
$message = $this->language->translateLabel(
'invalidBicLength',
'messages',
'CBankverbindungen'
);
throw new BadRequest($message);
}
// BIC-Regex: AAAA BB CC DDD
if (!preg_match('/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/', $bicClean)) {
$message = $this->language->translateLabel(
'invalidBicFormat',
'messages',
'CBankverbindungen'
);
throw new BadRequest($message);
}
}
}
private function validateIban(string $iban): bool
{
if (strlen($iban) < 15) {
return false;
}
// IBAN umstellen: erste 4 Zeichen ans Ende
$rearranged = substr($iban, 4) . substr($iban, 0, 4);
// Buchstaben in Zahlen umwandeln (A=10, B=11, ..., Z=35)
$numeric = '';
for ($i = 0; $i < strlen($rearranged); $i++) {
$char = $rearranged[$i];
if (ctype_alpha($char)) {
$numeric .= (string)(ord($char) - ord('A') + 10);
} else {
$numeric .= $char;
}
}
// Modulo-97-Prüfung: Ergebnis muss 1 sein
return $this->bcmod($numeric, '97') === '1';
}
private function bcmod(string $number, string $modulus): string
{
// Modulo für sehr große Zahlen in Schritten
$take = 9;
$mod = '';
do {
$a = (int)($mod . substr($number, 0, $take));
$number = substr($number, $take);
$mod = (string)($a % (int)$modulus);
} while (strlen($number) > 0);
return $mod;
}
}
i18n-Messages (erforderlich):
// custom/Espo/Custom/Resources/i18n/de_DE/CBankverbindungen.json
{
"messages": {
"invalidIbanChecksum": "IBAN-Prüfsumme ungültig (Modulo-97-Fehler)",
"invalidBicLength": "BIC muss 8 oder 11 Zeichen lang sein",
"invalidBicFormat": "BIC-Format ungültig (erwarte: AAAAAA BB CC DDD)"
}
}
Best Practice:
- ✅ Normalisierung VOR Validierung
- ✅ i18n für Fehlermeldungen
- ✅ BadRequest mit Fehlertext werfen
- ✅ Mathematisch korrekte Algorithmen (Modulo-97)
Beispiel 2: Hash-Berechnung (CDokumente)
Datei: custom/Espo/Custom/Hooks/CDokumente/CDokumente.php
Use Case: Automatische MD5/SHA256-Hash-Berechnung für Datei-Uploads
<?php
namespace Espo\Custom\Hooks\CDokumente;
use Espo\ORM\Entity;
class CDokumente extends \Espo\Core\Hooks\Base
{
public function beforeSave(Entity $entity, array $options = [])
{
$dokument = $entity->get('dokument');
if (!$dokument) {
return;
}
// Attachment laden
if (is_object($dokument)) {
$attachment = $dokument;
} else {
$attachment = $this->getEntityManager()
->getEntity('Attachment', $dokument);
}
if (!$attachment) {
return;
}
// Dateipfad prüfen
$filePath = 'data/upload/' . $attachment->get('id');
if (!file_exists($filePath)) {
return;
}
// Hash-Berechnung
$newMd5 = hash_file('md5', $filePath);
$newSha256 = hash_file('sha256', $filePath);
$entity->set('md5sum', $newMd5);
$entity->set('sha256', $newSha256);
// Status-Erkennung
if ($entity->isNew()) {
$entity->set('fileStatus', 'new');
} else {
$oldMd5 = $entity->getFetched('md5sum');
$oldSha256 = $entity->getFetched('sha256');
if ($oldMd5 !== $newMd5 || $oldSha256 !== $newSha256) {
$entity->set('fileStatus', 'changed');
} else {
$entity->set('fileStatus', 'synced');
}
}
}
}
Hinweis:
EspoCRM markiert Datei-Uploads nicht als Feldänderung (isAttributeChanged('dokument') = false). Daher läuft der Hook bei jedem Save mit Dokument-Feld.
Best Practice:
- ✅ File-Existence-Check vor Hash-Berechnung
- ✅ Unterstütze sowohl Object als auch ID
- ✅ Nutze
isNew()für Status-Logik - ✅ Dokumentiere API-Limitationen als Kommentar
Beispiel 3: Statistik-Berechnung (CPuls)
Datei: custom/Espo/Custom/Hooks/CPuls/UpdateTeamStats.php
Use Case: Automatische Berechnung von Zählern für verwandte Entities
<?php
namespace Espo\Custom\Hooks\CPuls;
use Espo\ORM\Entity;
use Espo\ORM\Repository\Option\SaveOptions;
use Espo\Core\Hook\Hook\BeforeSave;
class UpdateTeamStats implements BeforeSave
{
public function __construct(
private \Espo\ORM\EntityManager $entityManager
) {}
public function beforeSave(Entity $entity, SaveOptions $options): void
{
// Dokumente zählen
if ($entity->isNew() || $entity->isAttributeChanged('id')) {
$dokumenteCount = $this->entityManager
->getRDBRepository('CDokumente')
->where(['pulsId' => $entity->getId()])
->count();
$entity->set('anzahlDokumente', $dokumenteCount);
}
// Team-Zuordnungen analysieren
$zuordnungen = $this->entityManager
->getRDBRepository('CPulsTeamZuordnung')
->where(['pulsId' => $entity->getId()])
->find();
$aktiv = 0;
$abgeschlossen = 0;
foreach ($zuordnungen as $z) {
if ($z->get('aktiv')) {
$aktiv++;
if ($z->get('abgeschlossen')) {
$abgeschlossen++;
}
}
}
$entity->set('anzahlTeamsAktiv', $aktiv);
$entity->set('anzahlTeamsAbgeschlossen', $abgeschlossen);
}
}
Best Practice:
- ✅ Moderne Interface-basierte Hook-Klasse
- ✅ Constructor Injection für EntityManager
- ✅ Private Typed Properties (PHP 8.2+)
- ✅ Bedingte Berechnung (
isNew(),isAttributeChanged()) - ✅ Repository queries statt direktes SQL
Best Practices für Hooks
✅ DO
-
Nutze Interface-basierte Hooks (EspoCRM 9.x)
class MyHook implements BeforeSave { } -
Constructor Injection für Dependencies
public function __construct( private EntityManager $entityManager ) {} -
Validierung in beforeSave, Notifications in afterSave
beforeSave: Synchron, blockiert TransactionafterSave: Transaction bereits committed
-
Exception werfen bei Validierungsfehlern
throw new BadRequest('Error message'); throw new Forbidden('Access denied'); -
i18n für Fehlermeldungen
$this->language->translateLabel('key', 'messages', 'EntityName'); -
Performance-Optimierung mit Conditions
if ($entity->isNew() || $entity->isAttributeChanged('field')) { // Nur bei Änderung ausführen }
❌ DON'T
-
Keine komplexe Business-Logic in Hooks → Nutze Services stattdessen
-
Keine direkten SQL-Queries → Nutze EntityManager/Repositories
-
Keine externe API-Calls in beforeSave → Kann Transaction blockieren, nutze afterSave oder Queue
-
Keine Circular Dependencies → Hook A speichert Entity B, Hook B speichert Entity A = Endlosschleife
-
Keine Hooks für UI-Logic → Nutze Frontend-Controller
Hook-Reihenfolge
Entity Save Lifecycle:
1. beforeSave Hook
2. Entity Validation
3. Database Transaction START
4. INSERT/UPDATE Query
5. Transaction COMMIT
6. afterSave Hook
7. Stream/Notification
Wichtig:
beforeSave: Änderungen im Entity werden gespeichertafterSave: Entity ist bereits committed, Änderungen erfordern separatessaveEntity()
Debugging Hooks
Log-Output:
$GLOBALS['log']->debug('MyHook: ' . json_encode([
'entity' => $entity->getEntityType(),
'id' => $entity->getId(),
'isNew' => $entity->isNew(),
'changed' => $entity->get('field')
]));
Log-File:
tail -f data/logs/espo-$(date +%Y-%m-%d).log | grep MyHook
Fehlersuche:
-
Hook wird nicht ausgeführt
- Clear Cache:
php clear_cache.php - Rebuild:
php rebuild.php - Prüfe Namespace/Klassennamen
- Clear Cache:
-
Exception in Hook
- Prüfe Log:
data/logs/espo-{date}.log - Prüfe Type Hints (PHP 8.2 strict types)
- Validiere Constructor Injection
- Prüfe Log:
-
Hook läuft mehrfach
- Prüfe auf Circular Dependencies
- Nutze Conditions (
isAttributeChanged())
Troubleshooting
Problem: Hook läuft nicht
# Cache clearen
php clear_cache.php
# Rebuild
php rebuild.php
# Hook-Datei prüfen
php -l custom/Espo/Custom/Hooks/{Entity}/{HookName}.php
Problem: Circular Dependency
// ❌ FALSCH: Endlosschleife
class HookA implements BeforeSave {
public function beforeSave(Entity $entity, SaveOptions $options): void {
$entityB = $this->entityManager->getEntity('EntityB', 'id');
$entityB->set('field', 'value');
$this->entityManager->saveEntity($entityB); // triggert HookB
}
}
class HookB implements BeforeSave {
public function beforeSave(Entity $entity, SaveOptions $options): void {
$entityA = $this->entityManager->getEntity('EntityA', 'id');
$entityA->set('field', 'value');
$this->entityManager->saveEntity($entityA); // triggert HookA → LOOP!
}
}
// ✅ RICHTIG: Mit Flag
class HookA implements BeforeSave {
public function beforeSave(Entity $entity, SaveOptions $options): void {
if ($options->get('skipHooks')) {
return;
}
$entityB = $this->entityManager->getEntity('EntityB', 'id');
$entityB->set('field', 'value');
$this->entityManager->saveEntity($entityB, [
'skipHooks' => true
]);
}
}
Workflow-Management
Workflow-Dateien
Verzeichnis: custom/workflows/
Format: JSON (Simple Workflow oder BPM Flowchart)
Simple Workflow Beispiel
{
"type": "simple",
"name": "auto-assign-new-entity",
"entity_type": "CMyEntity",
"trigger_type": "afterRecordCreated",
"is_active": true,
"description": "Auto-assign new records to team",
"conditions_all": [
{
"type": "isEmpty",
"attribute": "assignedUserId"
}
],
"actions": [
{
"type": "applyAssignmentRule",
"targetTeamId": "team-id-here"
},
{
"type": "sendEmail",
"to": "assignedUser",
"emailTemplateId": "template-id"
}
]
}
Workflow Import/Export
# Alle Workflows exportieren
php custom/scripts/workflow_manager.php export
# Workflow importieren
php custom/scripts/workflow_manager.php import custom/workflows/my-workflow.json
# Workflows auflisten
php custom/scripts/workflow_manager.php list
Testing & Validierung
Validierungs-Tool v2.0 (Erweitert)
Haupt-Tool: custom/scripts/validate_and_rebuild.py
Verwendung
# Standard: Minimaler Output, vollständige Tests
python3 custom/scripts/validate_and_rebuild.py
# Verbose: Detaillierte Ausgabe für Debugging
python3 custom/scripts/validate_and_rebuild.py -v
# Nur Validierung (kein Rebuild)
python3 custom/scripts/validate_and_rebuild.py --dry-run
# Ohne Entity CRUD-Tests
python3 custom/scripts/validate_and_rebuild.py --skip-tests
# Mit Custom-Credentials
python3 custom/scripts/validate_and_rebuild.py --username admin --password secret
# Mit Custom Base-URL
python3 custom/scripts/validate_and_rebuild.py --base-url http://my-espo.local
Was das Tool prüft
Phase 1: Statische Validierung
- ✅ JSON-Syntax aller Custom-Dateien
- ✅ Relationship-Konsistenz (bidirektionale Links)
- ✅ Erforderliche Dateien (scopes, i18n)
- ✅ Dateirechte (www-data:www-data) + Auto-Fix
- ✅ PHP-Syntax (php -l)
Phase 2: Rebuild mit Log-Prüfung 6. ✅ Cache-Clearing 7. ✅ EspoCRM Rebuild 8. ✅ Log-Prüfung direkt nach Rebuild (mit präzisen Zeitstempeln)
Phase 3: Live Entity-Tests (wenn nicht übersprungen) 9. ✅ CREATE - Eintrag erstellen + Log-Check 10. ✅ READ - Eintrag lesen + Log-Check 11. ✅ UPDATE - Eintrag aktualisieren + Log-Check 12. ✅ LIST - Liste abrufen + Log-Check 13. ✅ DELETE - Eintrag löschen + Log-Check 14. ✅ Automatisches Cleanup aller Test-Records
Neue Features (v2.0)
1. Log-Integration:
- Nach jedem API-Request werden Logs geprüft
- Präzise Zeitstempel (nur Logs seit Request-Start)
- Test bricht ab bei Fehler in Logs
- Filtert bekannte unwichtige Meldungen
2. Intelligenter Output:
- Standard-Modus: Nur Ergebnisse (✓/✗)
- Verbose-Modus (
-v): Detaillierte Informationen, Timing, Debugging
3. CRUD-Tests für alle Entities:
- Testet jede Custom-Entity einzeln
- Validiert kompletten Lifecycle
- Prüft API-Responses
- Detektiert Service-Klassen-Fehler
4. KI-freundliches Feedback:
{
"status": "success",
"summary": {
"errors": 0,
"warnings": 1,
"entities_checked": 28
},
"errors": [],
"warnings": ["..."],
"recommendations": [
"Fehlende Service-Klassen - erstelle für jede Entity eine Service-Klasse",
"Unvollständige Übersetzungen - füge fehlende i18n-Dateien hinzu"
]
}
5. Fehler-Abbruch bei Log-Errors:
- Script bricht sofort ab wenn Rebuild Fehler in Logs produziert
- Zeigt relevante Fehlermeldungen an
- Keine weiteren Tests bei kritischen Fehlern
Typischer Workflow
# 1. Nach Code-Änderungen: Quick Check
python3 custom/scripts/validate_and_rebuild.py --dry-run
# 2. Vor Deployment: Vollständiger Test
python3 custom/scripts/validate_and_rebuild.py -v
# 3. Rebuild ohne Tests (schneller)
python3 custom/scripts/validate_and_rebuild.py --skip-tests
Bei Fehlern: Das Tool zeigt automatisch:
- Fehlerlog-Analyse
- Betroffene Dateien
- Konkrete Fehlermeldungen
- Empfohlene Fixes
End-to-End Tests
Tool: custom/scripts/e2e_tests.py
# E2E Tests ausführen
python3 custom/scripts/e2e_tests.py
Tests:
- CRUD für alle Custom Entities
- Relationship-Verknüpfungen
- ACL-Prüfungen
Manuelle Tests
Checkliste:
- Entity in UI sichtbar?
- Felder editierbar?
- Relationships funktionieren?
- Formulas triggern korrekt?
- Workflows aktiv?
- API-Endpoints erreichbar?
- ACL-Regeln greifen?
Fehlerbehandlung
Log-Files
Verzeichnis: data/logs/
Haupt-Logfile: espo-{YYYY-MM-DD}.log
# Letzte Fehler anzeigen
tail -50 data/logs/espo-$(date +%Y-%m-%d).log | grep -i error
# Live-Monitoring
tail -f data/logs/espo-$(date +%Y-%m-%d).log
Häufige Fehler
1. Layout-Fehler: "false" statt "{}"
Problem: EspoCRM 7.x+ erfordert {} statt false als Platzhalter
Falsch:
{
"rows": [
[
{"name": "field1"},
false
]
]
}
Richtig:
{
"rows": [
[
{"name": "field1"},
{}
]
]
}
2. Relationship nicht bidirektional
Problem: foreign zeigt nicht zurück
Falsch:
// Entity A
"links": {
"entityB": {
"type": "hasMany",
"entity": "EntityB",
"foreign": "wrongName" // ❌
}
}
// Entity B
"links": {
"entityA": {
"type": "belongsTo",
"entity": "EntityA",
"foreign": "entityB"
}
}
Richtig:
// Entity A
"links": {
"entityB": {
"type": "hasMany",
"entity": "EntityB",
"foreign": "entityA" // ✅ Zeigt auf Link-Namen in B
}
}
// Entity B
"links": {
"entityA": {
"type": "belongsTo",
"entity": "EntityA",
"foreign": "entityB" // ✅ Zeigt auf Link-Namen in A
}
}
3. i18n fehlt für en_US
Problem: Nur de_DE vorhanden, en_US fehlt
Lösung: IMMER beide Sprachen pflegen! en_US ist Fallback.
4. Dateirechte falsch
Problem: Files gehören root statt www-data
Lösung: Automatisch via validate_and_rebuild.py oder manuell:
sudo chown -R www-data:www-data custom/
sudo find custom/ -type f -exec chmod 664 {} \;
sudo find custom/ -type d -exec chmod 775 {} \;
5. ACL: 403 Forbidden
Problem: Role hat keine Rechte auf Entity
Lösung: ACL in Admin UI oder via SQL:
UPDATE role
SET data = JSON_SET(data,
'$.table.CMyEntity',
JSON_OBJECT('create', 'yes', 'read', 'all', 'edit', 'all', 'delete', 'all')
)
WHERE name = 'RoleName';
Deployment-Prozess
Standard-Workflow
# 1. Code-Änderungen durchführen
vim custom/Espo/Custom/Resources/metadata/entityDefs/CMyEntity.json
# 2. Validierung + Rebuild
python3 custom/scripts/validate_and_rebuild.py
# 3. Bei Erfolg: Commit
git add custom/
git commit -m "feat: Add CMyEntity with custom fields"
git push
Quick Rebuild (nach kleinen Änderungen)
docker exec espocrm php command.php clear-cache
docker exec espocrm php command.php rebuild
Nach Änderungen an Relationships
IMMER:
- Cache löschen
- Rebuild ausführen
- Browser-Cache löschen (Ctrl+F5)
Troubleshooting
Rebuild schlägt fehl
1. Logs prüfen:
python3 custom/scripts/validate_and_rebuild.py
# → Zeigt automatisch Fehlerlog-Analyse
2. Manuell Logs checken:
tail -100 data/logs/espo-$(date +%Y-%m-%d).log
3. PHP-Fehler:
docker exec espocrm php -l custom/Espo/Custom/Controllers/MyController.php
Entity nicht sichtbar
Checklist:
tab: truein scopes?disabled: falsein scopes?- ACL-Rechte für Role?
- Cache gelöscht?
- Rebuild durchgeführt?
Relationship funktioniert nicht
Checklist:
- Bidirektional konfiguriert?
foreignzeigt korrekt zurück?relationNameidentisch (bei M2M)?- Rebuild durchgeführt?
API gibt 404
Checklist:
- Controller existiert?
- Service existiert?
- Action-Methode korrekt benannt? (postAction..., getAction...)
- ACL-Rechte?
⚠️ KRITISCH: InjectableFactory Error (Service-Klasse fehlt)
Fehlermeldung in Logs:
CRITICAL: (0) InjectableFactory: Class 'Espo\Custom\Services\{EntityName}' does not exist.
:: GET /{EntityName} :: /var/www/html/application/Espo/Core/InjectableFactory.php(164)
Symptome:
- Entity in UI sichtbar, aber nicht aufrufbar
- API-Requests schlagen fehl (leer oder 500 Error)
- Fehler tritt bei JEDEM Zugriff auf die Entity auf
- Rebuild erfolgreich, aber Funktionalität fehlt
Ursache:
Für die Custom Entity {EntityName} wurde keine Service-Klasse erstellt. EspoCRM sucht nach custom/Espo/Custom/Services/{EntityName}.php und findet sie nicht.
Lösung:
Schritt 1: Erstelle Service-Datei
# Erstelle Datei
touch custom/Espo/Custom/Services/{EntityName}.php
Schritt 2: Füge minimale Service-Klasse ein
Datei: custom/Espo/Custom/Services/{EntityName}.php
<?php
namespace Espo\Custom\Services;
use Espo\Services\Record;
/**
* Service für {EntityName} Entity
*/
class {EntityName} extends Record
{
// Basis-Funktionalität wird von Record geerbt
}
Schritt 3: Rebuild + Cache Clear
python3 custom/scripts/validate_and_rebuild.py
Schritt 4: Verifizieren
# Prüfe ob Fehler weg sind
docker exec espocrm bash -c 'tail -n 50 /var/www/html/data/logs/espo-$(date +%Y-%m-%d).log | grep -i "InjectableFactory"'
# Test API-Zugriff
docker exec espocrm bash -c 'curl -s -X GET "http://localhost/api/v1/{EntityName}" -u "admin:admin"'
Prävention:
- ✅ Immer Service-Klasse bei Entity-Erstellung mit anlegen
- ✅ Nutze
validate_and_rebuild.py- detektiert fehlende Services - ✅ Prüfe Logs nach Rebuild auf InjectableFactory-Fehler
- ✅ Teste Entity-Zugriff nach Erstellung
Real-World-Beispiel (März 2026):
Problem:
- Entities
CAICollectionundCAdvowareAktennicht aufrufbar - Logs zeigten:
Class 'Espo\Custom\Services\CAICollection' does not exist
Lösung:
# Service-Klassen erstellt
cat > custom/Espo/Custom/Services/CAICollection.php << 'EOF'
<?php
namespace Espo\Custom\Services;
use Espo\Services\Record;
class CAICollection extends Record {}
EOF
cat > custom/Espo/Custom/Services/CAdvowareAkten.php << 'EOF'
<?php
namespace Espo\Custom\Services;
use Espo\Services\Record;
class CAdvowareAkten extends Record {}
EOF
# Rebuild
python3 custom/scripts/validate_and_rebuild.py
Ergebnis: ✅ Beide Entities sofort funktionsfähig, keine Fehler mehr in Logs
Formula triggert nicht
Checklist:
- In
metadata/formula/statt entityDefs? - Syntax korrekt?
- Rebuild durchgeführt?
Bekannte i18n-Warnungen (nicht kritisch)
Stand: März 2026
Die folgenden i18n-Link-Labels fehlen aktuell (funktional keine Auswirkung):
⚠ CDokumente (en_US): Link 'cAICollections' fehlt in i18n
⚠ CAICollections (de_DE): Link 'meetings' fehlt in i18n
⚠ CAICollections (de_DE): Link 'cDokumente' fehlt in i18n
⚠ CAICollections (en_US): Link 'cDokumente' fehlt in i18n
Behebung (optional):
Datei: custom/Espo/Custom/Resources/i18n/de_DE/CDokumente.json
{
"links": {
"cAICollections": "AI Collections"
}
}
Datei: custom/Espo/Custom/Resources/i18n/en_US/CDokumente.json
{
"links": {
"cAICollections": "AI Collections"
}
}
Datei: custom/Espo/Custom/Resources/i18n/de_DE/CAICollections.json
{
"links": {
"cDokumente": "Dokumente",
"meetings": "Meetings"
}
}
Datei: custom/Espo/Custom/Resources/i18n/en_US/CAICollections.json
{
"links": {
"cDokumente": "Documents",
"meetings": "Meetings"
}
}
Projekt-spezifische Entities
Übersicht
- CMietobjekt - Mietobjekte (Wohnungen/Häuser)
- CVmhMietverhltnis - Mietverhältnisse
- CKuendigung - Kündigungen
- CBeteiligte - Beteiligte Personen
- CMietinkasso - Mietinkasso-Verfahren
- CVmhRumungsklage - Räumungsklagen
- CDokumente - Dokumente
- CPuls - Puls-System (Entwicklungen)
- CAICollections - AI Collections
Entity-Graph
CMietobjekt
├── CVmhMietverhltnis (hasMany)
│ ├── CKuendigung (hasMany)
│ │ └── CVmhRumungsklage (hasOne)
│ ├── CMietinkasso (hasMany)
│ └── CBeteiligte (hasMany)
└── Contact (hasMany)
CDokumente
├── parent → [CVmhRumungsklage, CMietinkasso, CKuendigung]
└── CAICollections (hasMany via Junction)
└── CPuls (hasMany)
Tools & Scripts
Übersicht
| Tool | Zweck | Ausführung |
|---|---|---|
| validate_and_rebuild.py | Validierung + Rebuild | python3 custom/scripts/validate_and_rebuild.py |
| e2e_tests.py | End-to-End Tests | python3 custom/scripts/e2e_tests.py |
| ki_project_overview.py | Projekt-Analyse für AI | python3 custom/scripts/ki_project_overview.py |
| workflow_manager.php | Workflow-Verwaltung | php custom/scripts/workflow_manager.php list |
KI-Projekt-Übersicht
Für AI Code Agents:
python3 custom/scripts/ki_project_overview.py > /tmp/project-overview.txt
# → Gibt vollständigen Projekt-Status für AI aus
Ressourcen
Dokumentation
- EspoCRM Docs: https://docs.espocrm.com/
- API Reference: https://docs.espocrm.com/development/api/
- Formula Functions: https://docs.espocrm.com/administration/formula/
Projekt-Dokumentation
custom/docs/ESPOCRM_BEST_PRACTICES.md- Dieses Dokumentcustom/scripts/QUICKSTART.md- Quick Start Guidecustom/scripts/VALIDATION_TOOLS.md- Validierungs-Toolscustom/scripts/E2E_TESTS_README.md- E2E Testscustom/README.md- Custom Actions Blueprintcustom/TESTERGEBNISSE_JUNCTION_TABLE.md- Junction Table Implementation
Glossar
ACL - Access Control List (Zugriffsrechte)
Entity - Datenmodell (z.B. CMietobjekt)
Link - Relationship zwischen Entities
Junction Table - Verbindungstabelle für Many-to-Many
Formula - Berechnete Felder oder Automation-Scripts
Scope - Entity-Konfiguration (Tab, ACL, etc.)
Stream - Activity Feed einer Entity
Hook - Lifecycle-Event-Handler
Service - Business Logic Layer
Controller - API Request Handler
Repository - Data Access Layer
Ende der Best Practices Dokumentation
Für spezifische Fragen oder Updates: Siehe /custom/docs/ Verzeichnis.