Files
espocrm/custom/docs/ESPOCRM_BEST_PRACTICES.md

59 KiB

EspoCRM Best Practices & Entwicklungsrichtlinien

Version: 2.3
Datum: 11. März 2026
Zielgruppe: AI Code Agents & Entwickler


🔄 Letzte Änderungen (v2.3 - 11. März 2026)

Neue Features:

  • Junction Table UI-Pattern: columnAttributeMap + notStorable für UI-Anzeige von Junction-Spalten
  • Dokumenten-Propagierung: Hook-Pattern für automatische Verknüpfung zwischen hierarchischen Entities
  • Loop-Schutz: Statisches Processing-Array Pattern für rekursive Hooks
  • Troubleshooting: Vergessene Indizes auf gelöschte Felder (häufiger Rebuild-Fehler)

Dokumentierte Real-World Implementierung:

  • CAdvowareAkten/CAIKnowledge Junction Tables mit additionalColumns (hnr, syncstatus, lastSync)
  • Propagierungs-Hooks: Räumungsklage ↔ AdvowareAkten ↔ AIKnowledge
  • Sync-Status-Management mit globalen und Junction-level Status-Feldern
  • Hook-Chain für automatische Status-Propagierung bei Dokumentänderungen

🔄 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

  1. Projekt-Übersicht
  2. Architektur-Prinzipien
  3. Entity-Entwicklung
  4. Relationship-Patterns
  5. API-Entwicklung
  6. Hook-Entwicklung
  7. Workflow-Management
  8. Testing & Validierung
  9. Fehlerbehandlung
  10. Deployment-Prozess
  11. 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-Felder
  • Call - Custom Call-Felder
  • User - User-Erweiterungen
  • Meeting - Meeting-Erweiterungen
  • Email - E-Mail-Anpassungen
  • Task - Task-Anpassungen
  • PhoneNumber - Telefonnummern-Erweiterungen
  • Team - Team-Anpassungen
  • BpmnUserTask - Workflow-Task-Erweiterungen

Implementierte Hooks:

  1. CBankverbindungen/BankdatenValidation - IBAN/BIC-Validierung mit Modulo-97
  2. CDokumente/CDokumente - MD5/SHA256-Hash-Berechnung für Uploads
  3. 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 - Mietobjekte
  • CVmhMietverhltnis - Mietverhältnisse (VMH = Vermieter Helden)
  • CKuendigung - Kündigungen
  • CAICollections - 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 Navigation
  • acl: true - ACL-System aktiv
  • stream: true - Stream/Activity Feed
  • calendar: 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\Services sein
  • 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:

  1. Erstelle Service-Klasse SOFORT bei Entity-Erstellung
  2. Auch wenn initial leer, erstelle sie trotzdem
  3. Nutze Service für Business Logic statt Hooks
  4. Verwende Type Hints für bessere IDE-Unterstützung
  5. 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!

Junction-Spalten im UI anzeigen: columnAttributeMap & notStorable

Problem: additionalColumns sind nur via Junction-Entity-API zugänglich, nicht in Relationship-Panels.

Lösung: columnAttributeMap + notStorable Felder für UI-Anzeige

Beispiel: Dokumente mit HNR und Sync-Status in AdvowareAkten

Parent Entity (CAdvowareAkten):

{
  "fields": {
    "dokumenteHnr": {
      "type": "int",
      "notStorable": true,
      "utility": true
    },
    "dokumenteSyncstatus": {
      "type": "enum",
      "options": ["new", "unclean", "synced", "failed"],
      "notStorable": true,
      "utility": true
    },
    "dokumenteLastSync": {
      "type": "datetime",
      "notStorable": true,
      "utility": true
    },
    "dokumentes": {
      "type": "linkMultiple",
      "columns": {
        "hnr": "advowareAktenHnr",
        "syncstatus": "advowareAktenSyncstatus",
        "lastSync": "advowareAktenLastSync"
      },
      "view": "views/fields/link-multiple-with-columns"
    }
  },
  "links": {
    "dokumentes": {
      "type": "hasMany",
      "entity": "CDokumente",
      "foreign": "advowareAktens",
      "relationName": "cAdvowareAktenDokumente",
      "additionalColumns": {
        "hnr": {"type": "int"},
        "syncstatus": {"type": "varchar", "len": 20},
        "lastSync": {"type": "datetime"}
      },
      "columnAttributeMap": {
        "hnr": "dokumenteHnr",
        "syncstatus": "dokumenteSyncstatus",
        "lastSync": "dokumenteLastSync"
      }
    }
  }
}

Foreign Entity (CDokumente):

{
  "fields": {
    "advowareAktenHnr": {
      "type": "int",
      "notStorable": true,
      "utility": true,
      "layoutAvailabilityList": ["listForAdvowareAkten"]
    },
    "advowareAktenSyncstatus": {
      "type": "varchar",
      "notStorable": true,
      "utility": true,
      "layoutAvailabilityList": ["listForAdvowareAkten"]
    },
    "advowareAktenLastSync": {
      "type": "datetime",
      "notStorable": true,
      "utility": true,
      "layoutAvailabilityList": ["listForAdvowareAkten"]
    }
  },
  "links": {
    "advowareAktens": {
      "type": "hasMany",
      "entity": "CAdvowareAkten",
      "foreign": "dokumentes",
      "relationName": "cAdvowareAktenDokumente",
      "columnAttributeMap": {
        "hnr": "advowareAktenHnr",
        "syncstatus": "advowareAktenSyncstatus",
        "lastSync": "advowareAktenLastSync"
      }
    }
  }
}

Custom List Layout (layouts/CDokumente/listForAdvowareAkten.json):

[
  {"name": "name", "width": 25},
  {"name": "advowareAktenHnr", "width": 10},
  {"name": "advowareAktenSyncstatus", "width": 12},
  {"name": "advowareAktenLastSync", "width": 15},
  {"name": "description", "width": 20},
  {"name": "dokument", "width": 18}
]

Wichtige Konzepte:

  • notStorable: Feld wird nicht in Haupttabelle gespeichert
  • utility: Internes Feld, nicht in Standard-Formularen
  • columnAttributeMap: Bidirektionales Mapping Junction → UI
  • layoutAvailabilityList: Begrenzt Sichtbarkeit auf bestimmte Layouts
  • columns in linkMultiple-Field: Verbindet UI-Feldnamen mit Junction-Spalten

Workflow:

  1. EspoCRM liest Junction-Spalten über RDB-Funktionen
  2. Mapped sie via columnAttributeMap zu notStorable Feldern
  3. UI zeigt notStorable Felder in Relationship-Panels an
  4. Updates erfolgen via updateColumns() in Hooks

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: e53def10eea27b92a6cd00f40a3e09a4
  • dev-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 - Datenbankzugriff
  • Config - System-Konfiguration
  • Language - i18n-Übersetzungen
  • ServiceFactory - Service-Instanzen
  • EmailSender - E-Mail-Versand
  • Acl - ACL-Prüfungen
  • User - Aktueller Benutzer

Hook-Pattern: Dokumenten-Propagierung mit Loop-Schutz

Use Case: Automatische Verknüpfung von Dokumenten zwischen hierarchisch verbundenen Entities:

  • Räumungsklage ↔ AdvowareAkten ↔ AIKnowledge
  • Mietinkasso ↔ AdvowareAkten ↔ AIKnowledge

Challenge: Vermeide Endlos-Rekursion bei gegenseitiger Propagierung

Lösung: AfterRelate/AfterUnrelate Hooks mit statischem Processing-Array

Pattern-Beispiel (CVmhRumungsklage → AdvowareAkten + AIKnowledge):

<?php
namespace Espo\Custom\Hooks\CVmhRumungsklage;

use Espo\ORM\Entity;
use Espo\Core\Hook\Hook\AfterRelate;
use Espo\Core\Hook\Hook\AfterUnrelate;

class PropagateDocuments implements AfterRelate, AfterUnrelate
{
    private static array $processing = [];
    
    public function __construct(
        private \Espo\ORM\EntityManager $entityManager
    ) {}
    
    public function afterRelate(
        Entity $entity,
        string $relationName,
        Entity $foreignEntity,
        array $columnData,
        \Espo\ORM\Repository\Option\RelateOptions $options
    ): void {
        if ($relationName !== 'dokumentesvmhraumungsklage') {
            return;
        }
        
        // Loop-Schutz: Eindeutiger Key pro Operation
        $key = $entity->getId() . '-' . $foreignEntity->getId() . '-relate';
        if (isset(self::$processing[$key])) {
            return; // Bereits in Bearbeitung
        }
        self::$processing[$key] = true;
        
        try {
            // Hole verbundene AdvowareAkten
            $advowareAkten = $this->entityManager
                ->getRDBRepository('CVmhRumungsklage')
                ->getRelation($entity, 'advowareAkten')
                ->findOne();
            
            if ($advowareAkten) {
                $this->relateDocument($advowareAkten, 'dokumentes', $foreignEntity);
            }
            
            // Hole verbundene AIKnowledge
            $aIKnowledge = $this->entityManager
                ->getRDBRepository('CVmhRumungsklage')
                ->getRelation($entity, 'aIKnowledge')
                ->findOne();
            
            if ($aIKnowledge) {
                $this->relateDocument($aIKnowledge, 'dokumentes', $foreignEntity);
            }
        } catch (\Exception $e) {
            $GLOBALS['log']->error('PropagateDocuments Error: ' . $e->getMessage());
        } finally {
            unset(self::$processing[$key]); // Cleanup
        }
    }
    
    public function afterUnrelate(
        Entity $entity,
        string $relationName,
        Entity $foreignEntity,
        \Espo\ORM\Repository\Option\UnrelateOptions $options
    ): void {
        // Analog zu afterRelate, aber mit unrelate()
    }
    
    private function relateDocument(Entity $parent, string $relation, Entity $doc): void
    {
        $repository = $this->entityManager->getRDBRepository($parent->getEntityType());
        $relation = $repository->getRelation($parent, $relation);
        
        // Prüfe ob bereits verknüpft (vermeidet Duplikate)
        $isRelated = $relation->where(['id' => $doc->getId()])->findOne();
        
        if (!$isRelated) {
            $relation->relate($doc);
        }
    }
}

Propagierungs-Hierarchie:

┌─────────────────────┐
│  Räumungsklage      │
│  Mietinkasso        │
└──────────┬──────────┘
           │
    ┌──────┴──────┐
    ↓             ↓
┌──────────┐  ┌──────────┐
│AdvowareA.│  │AIKnowled.│
└────┬─────┘  └────┬─────┘
     │             │
     └──────┬──────┘
            ↓
     ┌──────────┐
     │ Dokument │
     └──────────┘

Down-Propagierung (Räumungsklage → unten):

  • Hook in Räumungsklage/Mietinkasso
  • Bei Dokumenten-Link → propagiere zu AdvowareAkten + AIKnowledge
  • Deren Hooks versuchen zurück zu propagieren → blockiert durch Loop-Schutz

Up-Propagierung (AdvowareAkten → oben):

  • Hook in AdvowareAkten/AIKnowledge
  • Bei Dokumenten-Link → propagiere zu Räumungsklage/Mietinkasso
  • Deren Hooks propagieren zu anderen Kind-Entities
  • Loop-Schutz verhindert Rück-Propagierung

Loop-Schutz Mechanismus:

  1. Statisches Array: private static array $processing = []
  2. Eindeutiger Key: {EntityID}-{DokumentID}-{Aktion}
  3. Check vor Ausführung: if (isset(self::$processing[$key])) return;
  4. Set bei Start: self::$processing[$key] = true;
  5. Cleanup: finally { unset(self::$processing[$key]); }

Vorteile:

  • Verhindert Endlos-Rekursion
  • Ermöglicht parallele Verarbeitung verschiedener Dokumente
  • Automatisches Cleanup auch bei Exceptions
  • Key-basiert: Verschiedene Operations können gleichzeitig laufen

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

  1. Nutze Interface-basierte Hooks (EspoCRM 9.x)

    class MyHook implements BeforeSave { }
    
  2. Constructor Injection für Dependencies

    public function __construct(
        private EntityManager $entityManager
    ) {}
    
  3. Validierung in beforeSave, Notifications in afterSave

    • beforeSave: Synchron, blockiert Transaction
    • afterSave: Transaction bereits committed
  4. Exception werfen bei Validierungsfehlern

    throw new BadRequest('Error message');
    throw new Forbidden('Access denied');
    
  5. i18n für Fehlermeldungen

    $this->language->translateLabel('key', 'messages', 'EntityName');
    
  6. Performance-Optimierung mit Conditions

    if ($entity->isNew() || $entity->isAttributeChanged('field')) {
        // Nur bei Änderung ausführen
    }
    

DON'T

  1. Keine komplexe Business-Logic in Hooks → Nutze Services stattdessen

  2. Keine direkten SQL-Queries → Nutze EntityManager/Repositories

  3. Keine externe API-Calls in beforeSave → Kann Transaction blockieren, nutze afterSave oder Queue

  4. Keine Circular Dependencies → Hook A speichert Entity B, Hook B speichert Entity A = Endlosschleife

  5. 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 gespeichert
  • afterSave: Entity ist bereits committed, Änderungen erfordern separates saveEntity()

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:

  1. Hook wird nicht ausgeführt

    • Clear Cache: php clear_cache.php
    • Rebuild: php rebuild.php
    • Prüfe Namespace/Klassennamen
  2. Exception in Hook

    • Prüfe Log: data/logs/espo-{date}.log
    • Prüfe Type Hints (PHP 8.2 strict types)
    • Validiere Constructor Injection
  3. 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

  1. JSON-Syntax aller Custom-Dateien
  2. Relationship-Konsistenz (bidirektionale Links)
  3. Erforderliche Dateien (scopes, i18n)
  4. Dateirechte (www-data:www-data) + Auto-Fix
  5. 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:

  1. Cache löschen
  2. Rebuild ausführen
  3. 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: true in scopes?
  • disabled: false in scopes?
  • ACL-Rechte für Role?
  • Cache gelöscht?
  • Rebuild durchgeführt?

Relationship funktioniert nicht

Checklist:

  • Bidirektional konfiguriert?
  • foreign zeigt korrekt zurück?
  • relationName identisch (bei M2M)?
  • Rebuild durchgeführt?

API gibt 404

Checklist:

  • Controller existiert?
  • Service existiert?
  • Action-Methode korrekt benannt? (postAction..., getAction...)
  • ACL-Rechte?

⚠️ KRITISCH: Rebuild schlägt fehl - "Column does not exist"

Fehlermeldung:

Doctrine\DBAL\Schema\SchemaException::columnDoesNotExist('feldname', 'tabelle')
#2 /var/www/html/application/Espo/Core/Utils/Database/Schema/Builder.php(154):
Doctrine\DBAL\Schema\Table->addIndex(Array, 'IDX_FELDNAME', Array)

Symptome:

  • Rebuild schlägt fehl
  • JSON/PHP-Validierung erfolgreich
  • Fehlermeldung referenziert nicht existierendes Feld
  • Error tritt in Schema-Builder auf

Ursache: Ein Index wurde für ein Feld definiert, das nicht (mehr) existiert.

Häufigster Fall:

  1. Feld wird aus entityDefs entfernt
  2. Index-Definition wird vergessen
  3. Rebuild versucht Index auf nicht-existentes Feld zu erstellen

Beispiel aus Praxis:

// CDokumente.json
{
  "fields": {
    // "aktennr" wurde entfernt ← Feld gelöscht
  },
  "indexes": {
    "aktennr": {               Index noch da!
      "columns": ["aktennr"]
    }
  }
}

Lösung:

Schritt 1: Identifiziere betroffenes Feld und Entity aus Error-Log

columnDoesNotExist('aktennr', 'c_dokumente')
                    ^^^^^^^^   ^^^^^^^^^^^^
                    Feld       Tabelle

Schritt 2: Öffne entityDefs-Datei

code custom/Espo/Custom/Resources/metadata/entityDefs/CDokumente.json

Schritt 3: Suche Index-Definition und entferne sie

// VORHER:
"indexes": {
  "createdAtId": {...},
  "aktennr": {               ENTFERNEN
    "columns": ["aktennr"]
  },
  "md5sum": {...}
}

// NACHHER:
"indexes": {
  "createdAtId": {...},
  "md5sum": {...}
}

Schritt 4: Rebuild erneut durchführen

python3 custom/scripts/validate_and_rebuild.py

Best Practice: Bei Feld-Entfernung immer prüfen:

  1. Feld aus fields entfernt?
  2. Link aus links entfernt?
  3. Index aus indexes entfernt? ← Oft vergessen!
  4. Layout-Definitionen aktualisiert?
  5. i18n-Einträge bereinigt?

⚠️ 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:

  1. Immer Service-Klasse bei Entity-Erstellung mit anlegen
  2. Nutze validate_and_rebuild.py - detektiert fehlende Services
  3. Prüfe Logs nach Rebuild auf InjectableFactory-Fehler
  4. Teste Entity-Zugriff nach Erstellung

Real-World-Beispiel (März 2026):

Problem:

  • Entities CAICollection und CAdvowareAkten nicht 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

  1. CMietobjekt - Mietobjekte (Wohnungen/Häuser)
  2. CVmhMietverhltnis - Mietverhältnisse
  3. CKuendigung - Kündigungen
  4. CBeteiligte - Beteiligte Personen
  5. CMietinkasso - Mietinkasso-Verfahren
  6. CVmhRumungsklage - Räumungsklagen
  7. CDokumente - Dokumente
  8. CPuls - Puls-System (Entwicklungen)
  9. 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

Projekt-Dokumentation

  • custom/docs/ESPOCRM_BEST_PRACTICES.md - Dieses Dokument
  • custom/scripts/QUICKSTART.md - Quick Start Guide
  • custom/scripts/VALIDATION_TOOLS.md - Validierungs-Tools
  • custom/scripts/E2E_TESTS_README.md - E2E Tests
  • custom/README.md - Custom Actions Blueprint
  • custom/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.