Files
espocrm/custom/docs/ESPOCRM_BEST_PRACTICES.md

41 KiB

EspoCRM Best Practices & Entwicklungsrichtlinien

Version: 2.1
Datum: 9. März 2026
Zielgruppe: AI Code Agents & Entwickler


📋 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

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

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

Haupt-Tool: custom/scripts/validate_and_rebuild.py

# Vollständige Validierung + Rebuild
python3 custom/scripts/validate_and_rebuild.py

# Nur Validierung (kein Rebuild)
python3 custom/scripts/validate_and_rebuild.py --dry-run

# Mit E2E Tests überspringen
python3 custom/scripts/validate_and_rebuild.py --skip-e2e

Das Tool prüft:

  1. JSON-Syntax aller Custom-Dateien
  2. Relationship-Konsistenz (bidirektionale Links)
  3. Formula-Script Platzierung
  4. i18n-Vollständigkeit (de_DE + en_US)
  5. Layout-Struktur (bottomPanelsDetail, detail.json)
  6. Dateirechte (www-data:www-data)
  7. CSS-Validierung (csslint)
  8. JavaScript-Validierung (jshint)
  9. PHP-Syntax (php -l)
  10. EspoCRM Rebuild
  11. E2E-Tests (CRUD-Operationen)

Bei Fehlern: Automatische Fehlerlog-Analyse der letzten 50 Log-Zeilen!

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?

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.