
Frappe Syntax Hooks Events
- 58 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Implement Frappe document lifecycle hooks via doc_events in hooks.py, understanding event order and extend vs override behavior across apps.
About
Guides implementing document lifecycle hooks via doc_events in hooks.py, including event order and cross-app overrides. A developer uses it when hooking into or overriding document behavior from another app.
- Implement document lifecycle hooks via doc_events in hooks.py
- Covers event execution order and extend vs override behavior
Frappe Syntax Hooks Events by the numbers
- 58 all-time installs (skills.sh)
- Ranked #3,178 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-syntax-hooks-eventsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Implement Frappe document lifecycle hooks via doc_events in hooks.py, understanding event order and extend vs override behavior across apps.
Files
Document Lifecycle Hooks (doc_events)
Quick Reference: Event Execution Order
Insert (new document)
| Order | Event | Purpose | Can Raise? |
|---|---|---|---|
| 1 | before_insert | Set defaults before naming | YES |
| 2 | before_naming | Modify naming logic | YES |
| 3 | autoname | Set the name property | YES |
| 4 | before_validate | Auto-set missing values | YES |
| 5 | validate | Validation logic — throw to abort | YES |
| 6 | before_save | Final mutations before DB write | YES |
| 7 | db_insert | Internal — writes row to DB | — |
| 8 | after_insert | Post-insert logic (runs once ever) | YES |
| 9 | on_update | Post-save logic (runs on every save) | YES |
| 10 | on_change | Fires if any field value changed | YES |
Save (existing document)
| Order | Event | Purpose |
|---|---|---|
| 1 | before_validate | Auto-set missing values |
| 2 | validate | Validation logic — throw to abort |
| 3 | before_save | Final mutations before DB write |
| 4 | db_update | Internal — updates row in DB |
| 5 | on_update | Post-save logic |
| 6 | on_change | Fires if any field value changed |
Submit
| Order | Event | Purpose |
|---|---|---|
| 1 | before_validate | Auto-set missing values |
| 2 | validate | Validation logic |
| 3 | before_save | Final mutations before DB write |
| 4 | before_submit | Pre-submit logic — throw to abort |
| 5 | db_update | Internal — updates row in DB |
| 6 | on_submit | Post-submit logic (GL entries etc) |
| 7 | on_update | Post-save logic |
| 8 | on_change | Fires if any field value changed |
Cancel
| Order | Event | Purpose |
|---|---|---|
| 1 | before_cancel | Pre-cancel validation |
| 2 | db_update | Internal — updates row in DB |
| 3 | on_cancel | Post-cancel logic (reverse GL etc) |
| 4 | on_change | Fires if any field value changed |
Delete
| Order | Event | Purpose |
|---|---|---|
| 1 | on_trash | Pre-delete cleanup |
| 2 | after_delete | Post-delete logic |
Other Operations
| Operation | Events (in order) |
|---|---|
| Rename | before_rename → after_rename |
| Amend | before_insert chain runs on the new amended doc |
| Update After Submit | before_update_after_submit → db_update → on_update_after_submit → on_change |
---
doc_events in hooks.py: Syntax
Basic Structure
# hooks.py
doc_events = {
"Sales Invoice": {
"on_submit": "myapp.events.sales_invoice.on_submit",
"on_cancel": "myapp.events.sales_invoice.on_cancel",
},
"Purchase Order": {
"validate": "myapp.events.purchase_order.validate",
}
}Wildcard: Apply to ALL DocTypes
doc_events = {
"*": {
"after_insert": "myapp.events.global_handler.after_insert_all",
"on_update": "myapp.events.global_handler.track_changes",
}
}ALWAYS use "*" (string with asterisk) as the key. This fires the handler for every DocType.
Multiple Handlers per Event
doc_events = {
"Sales Invoice": {
"on_submit": [
"myapp.events.accounting.create_gl_entries",
"myapp.events.notifications.send_invoice_email",
]
}
}Handler Function Signature
# myapp/events/sales_invoice.py
def on_submit(doc, method=None):
"""
doc — the Document instance (e.g., Sales Invoice)
method — string name of the event (e.g., "on_submit"), or None
"""
if doc.grand_total > 10000:
frappe.sendmail(...)ALWAYS accept method as the second parameter (with default None). Frappe passes it automatically.
---
Decision Tree: Which Event to Use
"I need to validate data before saving"
→ Use validate. ALWAYS raise frappe.throw() here to block invalid saves.
"I need to set default values automatically"
→ Use before_validate. This runs before validate, so your defaults are set before validation checks.
"I need to run logic only on first creation"
→ Use after_insert. This fires ONLY on insert, NEVER on subsequent saves.
"I need to run logic on every save (insert + update)"
→ Use on_update. This fires on both insert and save operations.
"I need to create linked documents after submit"
→ Use on_submit. NEVER create linked docs in validate — the document is not yet committed.
"I need to reverse linked documents on cancel"
→ Use on_cancel. ALWAYS clean up GL entries, stock ledger entries, and linked docs here.
"I need to modify the document name"
→ Use autoname in the controller, or before_naming for conditional logic.
"I need to prevent deletion under certain conditions"
→ Use on_trash. Raise frappe.throw() to block deletion.
"I need to update a submitted document's fields"
→ Use before_update_after_submit for validation and on_update_after_submit for side effects.
"I need logic that runs only when values actually changed"
→ Use on_change. This fires only when at least one field value differs from the DB state.
---
doc_events vs Controller Events
Both mechanisms trigger the SAME events. The difference is WHERE you register them.
| Aspect | Controller (class method) | doc_events (hooks.py) |
|---|---|---|
| Location | {doctype}.py controller file | hooks.py in your app |
| Use when | You OWN the DocType | You are EXTENDING another app's DocType |
| Execution | Runs first (controller) | Runs after controller method |
| Multiple apps | Only one controller per DocType | Multiple apps can register handlers |
ALWAYS use doc_events when hooking into a DocType you do NOT own. NEVER modify another app's controller file directly.
Execution Order Within a Single Event
For a given event (e.g., validate): 1. Controller method runs first (def validate(self)) 2. doc_events handlers run in app installation order 3. Wildcard "*" handlers run after specific DocType handlers
---
extend_doctype_class [v16+]
In Frappe v16+, extend_doctype_class provides a cleaner alternative to doc_events for adding methods to existing DocTypes.
hooks.py
extend_doctype_class = {
"Sales Invoice": [
"myapp.overrides.sales_invoice.SalesInvoiceExtension"
]
}Extension Class (Mixin)
# myapp/overrides/sales_invoice.py
import frappe
class SalesInvoiceExtension:
def validate(self):
"""This is called as part of the controller chain."""
if self.grand_total < 0:
frappe.throw("Grand total cannot be negative")
def custom_method(self):
"""Custom methods are also available on the doc instance."""
return self.itemsKey Rules
- ALWAYS use
extend_doctype_classoveroverride_doctype_classin v16+ when multiple apps may extend the same DocType. - Multiple apps can extend the same DocType — extensions stack via MRO.
- Class resolution order follows hooks priority:
class Final(App2Mixin, App1Mixin, Original). - Extension methods (like
validate) run as part of the controller, NOT as separate doc_events handlers.
---
override_doctype_class [v14+]
Completely replaces the controller class. Use with extreme caution.
# hooks.py
override_doctype_class = {
"ToDo": "myapp.overrides.todo.CustomToDo"
}# myapp/overrides/todo.py
from frappe.desk.doctype.todo.todo import ToDo
class CustomToDo(ToDo):
def validate(self):
super().validate() # ALWAYS call super() to preserve original logic
# Your additions hereNEVER use override_doctype_class if extend_doctype_class is available (v16+). Only ONE app can override a DocType — last-installed app wins, silently breaking other apps.
---
Multi-App Event Ordering
When multiple apps register doc_events for the same DocType and event:
1. Handlers execute in app installation order (as listed in sites/{site}/site_config.json → installed_apps). 2. The order can be changed via Setup > Installed Applications > Update Hooks Resolution Order. 3. For override_doctype_class, the last-installed app wins (only one override applies). 4. For extend_doctype_class (v16+), all extensions stack cumulatively.
---
Transaction Behavior
All document events from before_validate through on_change run inside a single database transaction.
- If ANY event raises an exception, the ENTIRE operation rolls back (including
db_insert/db_update). after_insert,on_update,on_submit,on_cancel— all run BEFORE the transaction commits.- The transaction commits only AFTER all events complete successfully.
after_deleteruns after the DELETE statement but still within the request transaction.
NEVER assume data is committed to DB inside any event handler. Other concurrent requests will NOT see your changes until the full request completes.
---
Critical Rules
1. ALWAYS use frappe.throw() to abort operations — NEVER use raise Exception. 2. NEVER modify doc.name outside of autoname or before_naming. 3. ALWAYS call super().{event}() when overriding controller methods in subclasses. 4. NEVER use doc.save() inside validate or before_save — this causes infinite recursion. 5. ALWAYS use doc.flags.ignore_permissions = True explicitly if your hook needs to bypass permissions — NEVER assume hooks run as Administrator. 6. NEVER put slow operations (API calls, file I/O) in validate — use after_insert or on_update with frappe.enqueue() instead. 7. ALWAYS use doc.flags to communicate between events in the same request (e.g., doc.flags.skip_notification = True). 8. NEVER rely on on_change for critical logic — it only fires when values actually differ from the database state.
---
See Also
- Event Execution Order — Detailed Diagrams
- Working Examples for Common Patterns
- Anti-Patterns and Common Mistakes
frappe-syntax-hooks-config— App-level hooks (scheduler, fixtures, permissions)- Official docs: https://docs.frappe.io/framework/user/en/basics/doctypes/controllers
Anti-Patterns and Common Mistakes
1. Calling doc.save() Inside validate or before_save
WRONG — causes infinite recursion:
def validate(doc, method=None):
doc.custom_field = "value"
doc.save() # NEVER — triggers validate again → infinite loopCORRECT — mutate the doc directly (it will be saved automatically):
def validate(doc, method=None):
doc.custom_field = "value" # Just set the value — Frappe saves it---
2. Using raise Instead of frappe.throw()
WRONG — raw exceptions bypass Frappe's error handling:
def validate(doc, method=None):
if not doc.customer:
raise ValueError("Customer is required") # NEVERCORRECT — frappe.throw() shows a user-friendly message and rolls back:
def validate(doc, method=None):
if not doc.customer:
frappe.throw("Customer is required") # ALWAYS---
3. Using after_insert for Logic That Should Run on Every Save
WRONG — after_insert fires only once (on creation):
doc_events = {
"Sales Invoice": {
"after_insert": "myapp.events.update_customer_balance" # Misses updates!
}
}CORRECT — use on_update for logic that must run on every save:
doc_events = {
"Sales Invoice": {
"on_update": "myapp.events.update_customer_balance"
}
}---
4. Creating Linked Documents in validate
WRONG — the parent document is not yet committed:
def validate(doc, method=None):
project = frappe.new_doc("Project")
project.sales_order = doc.name
project.insert() # NEVER — doc may not be saved if later validation failsCORRECT — create linked documents in on_submit or after_insert:
def on_submit(doc, method=None):
project = frappe.new_doc("Project")
project.sales_order = doc.name
project.insert() # Safe — doc is committed---
5. Slow Operations in Synchronous Handlers
WRONG — blocks the user's request:
def on_submit(doc, method=None):
import requests
response = requests.post("https://external-api.com/notify", json={...}) # NEVER
# User waits for external API responseCORRECT — use frappe.enqueue() for slow operations:
def on_submit(doc, method=None):
frappe.enqueue(
"myapp.tasks.notify_external",
queue="short",
doc_name=doc.name,
)---
6. Missing super() Call in Controller Overrides
WRONG — breaks parent class logic:
# override_doctype_class
class CustomToDo(ToDo):
def validate(self):
self.custom_field = "value"
# Missing super().validate() — original validation skipped!CORRECT — ALWAYS call super():
class CustomToDo(ToDo):
def validate(self):
super().validate() # ALWAYS call super() first
self.custom_field = "value"---
7. Assuming Hooks Run as Administrator
WRONG — hooks run as the current session user:
def on_submit(doc, method=None):
other_doc = frappe.get_doc("Salary Slip", doc.employee)
other_doc.custom_field = "value"
other_doc.save() # May fail with PermissionErrorCORRECT — explicitly set permission flags when needed:
def on_submit(doc, method=None):
other_doc = frappe.get_doc("Salary Slip", doc.employee)
other_doc.custom_field = "value"
other_doc.flags.ignore_permissions = True # ALWAYS be explicit
other_doc.save()---
8. Wrong Event Name (Silent Failure)
WRONG — misspelled event names are silently ignored:
doc_events = {
"Sales Invoice": {
"on_validated": "myapp.events.handler" # WRONG — correct name is "validate"
}
}Frappe does NOT warn about unrecognized event names. The handler simply never fires.
Valid event names (exhaustive list):
before_insert,after_insertbefore_naming,autonamebefore_validate,validatebefore_save,on_update,on_changebefore_submit,on_submitbefore_cancel,on_cancelon_trash,after_deletebefore_rename,after_renamebefore_update_after_submit,on_update_after_submit
ALWAYS copy event names from this list — NEVER type them from memory.
---
9. Modifying doc.name Outside of Naming Events
WRONG — causes database integrity issues:
def validate(doc, method=None):
doc.name = f"CUSTOM-{doc.name}" # NEVER modify name hereCORRECT — use autoname or before_naming only:
# In the controller class
class MyDocType(Document):
def autoname(self):
self.name = f"CUSTOM-{self.naming_series}"---
10. Relying on on_change for Critical Logic
WRONG — on_change only fires when values differ from DB:
doc_events = {
"Sales Invoice": {
"on_change": "myapp.events.send_critical_notification" # May not fire!
}
}If a user clicks "Save" without changing any values, on_change does NOT fire.
CORRECT — use on_update for logic that must run on every save:
doc_events = {
"Sales Invoice": {
"on_update": "myapp.events.send_critical_notification"
}
}---
11. Infinite Loop with Wildcard Handlers
WRONG — wildcard handler creates a doc, which triggers the handler again:
doc_events = {
"*": {
"on_update": "myapp.events.audit.log_all_changes"
}
}
def log_all_changes(doc, method=None):
frappe.get_doc({
"doctype": "Custom Log",
"message": f"{doc.doctype} updated"
}).insert() # This insert triggers on_update for "Custom Log" → infinite loop!CORRECT — ALWAYS exclude logging/audit DocTypes:
def log_all_changes(doc, method=None):
if doc.doctype in ("Custom Log", "Activity Log", "Comment", "Version"):
return # Break the loop
frappe.get_doc({
"doctype": "Custom Log",
"message": f"{doc.doctype} updated"
}).insert(ignore_permissions=True)---
12. Using override_doctype_class When Multiple Apps Extend
WRONG — only the last-installed app's override applies:
# App A hooks.py
override_doctype_class = {"Sales Invoice": "app_a.overrides.CustomSI"}
# App B hooks.py
override_doctype_class = {"Sales Invoice": "app_b.overrides.CustomSI"}
# App B silently wins — App A's override is lostCORRECT (v16+) — use extend_doctype_class for cumulative extensions:
# App A hooks.py
extend_doctype_class = {"Sales Invoice": ["app_a.mixins.SIMixin"]}
# App B hooks.py
extend_doctype_class = {"Sales Invoice": ["app_b.mixins.SIMixin"]}
# Both extensions apply via MROEvent Execution Order — Detailed Reference
Insert Operation (doc.insert())
Permission check
│
▼
before_insert ← Set defaults before naming
│
▼
before_naming ← Modify naming logic
│
▼
autoname ← Set doc.name
│
▼
before_validate ← Auto-set missing values
│
▼
validate ← Throw here to abort insert
│
▼
before_save ← Final mutations before DB write
│
▼
[db_insert] ← Row written to database (internal)
│
▼
after_insert ← Runs ONLY on insert (never on save)
│
▼
on_update ← Runs on every save (insert + update)
│
▼
on_change ← Runs only if values differ from DB
│
▼
[Transaction commits when request completes]Key insight: after_insert fires BEFORE on_update on insert. Both fire within the same transaction.
---
Save Operation (doc.save())
Permission check
│
▼
before_validate ← Auto-set missing values
│
▼
validate ← Throw here to abort save
│
▼
before_save ← Final mutations before DB write
│
▼
[db_update] ← Row updated in database (internal)
│
▼
on_update ← Post-save logic
│
▼
on_change ← Runs only if values differ from DB
│
▼
[Transaction commits when request completes]Key insight: before_insert, before_naming, autoname, and after_insert do NOT fire on save — only on insert.
---
Submit Operation (doc.submit())
Permission check (Submit permission required)
│
▼
before_validate ← Auto-set missing values
│
▼
validate ← Throw here to abort submit
│
▼
before_save ← Final mutations before DB write
│
▼
before_submit ← Pre-submit logic — throw to abort
│
▼
[db_update] ← docstatus set to 1, row updated
│
▼
on_submit ← Create GL entries, stock ledger, linked docs
│
▼
on_update ← Post-save logic
│
▼
on_change ← Runs only if values differ from DB
│
▼
[Transaction commits when request completes]Key insight: validate and before_save fire BEFORE before_submit. This means validation runs identically whether saving a draft or submitting.
---
Cancel Operation (doc.cancel())
Permission check (Cancel permission required)
│
▼
before_cancel ← Pre-cancel validation — throw to abort
│
▼
[db_update] ← docstatus set to 2, row updated
│
▼
on_cancel ← Reverse GL entries, stock ledger, linked docs
│
▼
on_change ← Runs only if values differ from DB
│
▼
[Transaction commits when request completes]Key insight: validate does NOT fire on cancel. ALWAYS put cancel-specific validation in before_cancel.
---
Delete Operation (doc.delete())
Permission check (Delete permission required)
│
▼
on_trash ← Pre-delete cleanup — throw to block deletion
│
▼
[DELETE FROM database] ← Row removed from database
│
▼
after_delete ← Post-delete logic (doc still in memory)
│
▼
[Transaction commits when request completes]Key insight: In after_delete, the doc object is still available in memory but the row is already deleted from the database. NEVER try to doc.save() in after_delete.
---
Update After Submit (doc.save() on submitted doc)
Permission check
│
▼
before_update_after_submit ← Validate allowed field changes
│
▼
[db_update] ← Row updated in database
│
▼
on_update_after_submit ← Post-update logic
│
▼
on_change ← Runs only if values differ from DB
│
▼
[Transaction commits when request completes]Key insight: Only fields marked "Allow on Submit" in the DocType can be modified. validate does NOT fire — use before_update_after_submit for validation.
---
Rename Operation (doc.rename())
Permission check
│
▼
before_rename(self, old_name, new_name, merge=False)
│
▼
[Database rename operation]
│
▼
after_rename(self, old_name, new_name, merge=False)Key insight: before_rename and after_rename receive extra parameters beyond the standard (self) signature.
---
Amend Operation (frappe.copy_doc + insert)
Amend is NOT a separate event chain. It works as follows:
1. frappe.copy_doc(original_doc) creates a copy 2. The copy gets amended_from = original_doc.name 3. docstatus is set to 0 (Draft) 4. The standard INSERT chain fires on the new document
ALWAYS check doc.amended_from inside before_insert or after_insert to detect if a document is an amendment.
---
Event Firing Matrix
| Event | Insert | Save | Submit | Cancel | Delete | Rename |
|---|---|---|---|---|---|---|
before_insert | YES | — | — | — | — | — |
before_naming | YES | — | — | — | — | — |
autoname | YES | — | — | — | — | — |
before_validate | YES | YES | YES | — | — | — |
validate | YES | YES | YES | — | — | — |
before_save | YES | YES | YES | — | — | — |
after_insert | YES | — | — | — | — | — |
before_submit | — | — | YES | — | — | — |
on_submit | — | — | YES | — | — | — |
before_cancel | — | — | — | YES | — | — |
on_cancel | — | — | — | YES | — | — |
on_update | YES | YES | YES | — | — | — |
on_change | YES | YES | YES | YES | — | — |
on_trash | — | — | — | — | YES | — |
after_delete | — | — | — | — | YES | — |
before_rename | — | — | — | — | — | YES |
after_rename | — | — | — | — | — | YES |
before_update_after_submit | — | — | — | — | — | — |
on_update_after_submit | — | — | — | — | — | — |
Note: `before_update_after_submit` and `on_update_after_submit` fire only when saving a submitted document (docstatus=1).
Working doc_events Examples
1. Validate Before Save — Block Invalid Data
# hooks.py
doc_events = {
"Sales Invoice": {
"validate": "myapp.events.sales_invoice.validate_invoice"
}
}# myapp/events/sales_invoice.py
import frappe
def validate_invoice(doc, method=None):
"""ALWAYS use frappe.throw() to block saves — NEVER use raise."""
if not doc.items:
frappe.throw("Sales Invoice must have at least one item")
for item in doc.items:
if item.rate <= 0:
frappe.throw(f"Row {item.idx}: Rate must be greater than zero")---
2. Set Defaults in before_validate
# hooks.py
doc_events = {
"Purchase Order": {
"before_validate": "myapp.events.purchase_order.set_defaults"
}
}# myapp/events/purchase_order.py
import frappe
def set_defaults(doc, method=None):
"""Set missing values BEFORE validation runs."""
if not doc.delivery_date:
doc.delivery_date = frappe.utils.add_days(doc.transaction_date, 7)
if not doc.payment_terms_template:
doc.payment_terms_template = frappe.db.get_single_value(
"Buying Settings", "payment_terms_template"
)---
3. Post-Submit Logic — Create Linked Documents
# hooks.py
doc_events = {
"Sales Order": {
"on_submit": "myapp.events.sales_order.create_project"
}
}# myapp/events/sales_order.py
import frappe
def create_project(doc, method=None):
"""Create a project when a Sales Order is submitted."""
if doc.project:
return # Project already linked
project = frappe.new_doc("Project")
project.project_name = f"Project for {doc.name}"
project.company = doc.company
project.sales_order = doc.name
project.expected_start_date = doc.delivery_date
project.flags.ignore_permissions = True
project.insert()
# Update the Sales Order with the project link
doc.db_set("project", project.name, update_modified=False)---
4. Cancel Logic — Reverse Linked Documents
# hooks.py
doc_events = {
"Sales Order": {
"on_cancel": "myapp.events.sales_order.cancel_linked_project"
}
}# myapp/events/sales_order.py
import frappe
def cancel_linked_project(doc, method=None):
"""ALWAYS clean up linked documents on cancel."""
if not doc.project:
return
project = frappe.get_doc("Project", doc.project)
if project.status != "Cancelled":
project.status = "Cancelled"
project.flags.ignore_permissions = True
project.save()---
5. Wildcard Handler — Global Audit Trail
# hooks.py
doc_events = {
"*": {
"on_update": "myapp.events.audit.log_change",
"on_trash": "myapp.events.audit.log_deletion",
}
}# myapp/events/audit.py
import frappe
def log_change(doc, method=None):
"""Log every document change. Runs for ALL DocTypes."""
if doc.doctype in ("Comment", "Version", "Activity Log"):
return # Avoid infinite loops on logging DocTypes
frappe.get_doc({
"doctype": "Activity Log",
"subject": f"{doc.doctype} {doc.name} updated by {frappe.session.user}",
"reference_doctype": doc.doctype,
"reference_name": doc.name,
}).insert(ignore_permissions=True)
def log_deletion(doc, method=None):
"""Log document deletions."""
if doc.doctype in ("Comment", "Version", "Activity Log"):
return
frappe.get_doc({
"doctype": "Activity Log",
"subject": f"{doc.doctype} {doc.name} deleted by {frappe.session.user}",
"reference_doctype": doc.doctype,
"reference_name": doc.name,
}).insert(ignore_permissions=True)---
6. Using doc.flags for Cross-Event Communication
# hooks.py
doc_events = {
"Sales Invoice": {
"validate": "myapp.events.sales_invoice.check_credit",
"on_submit": "myapp.events.sales_invoice.notify_customer",
}
}# myapp/events/sales_invoice.py
import frappe
def check_credit(doc, method=None):
"""Check credit limit during validation."""
customer_balance = get_customer_outstanding(doc.customer)
if customer_balance + doc.grand_total > get_credit_limit(doc.customer):
doc.flags.over_credit_limit = True
frappe.msgprint("Customer is over credit limit — manager approval required")
def notify_customer(doc, method=None):
"""Send notification on submit, with extra warning if over credit limit."""
template = "invoice_submitted"
if doc.flags.get("over_credit_limit"):
template = "invoice_submitted_over_credit"
frappe.sendmail(
recipients=[doc.contact_email],
template=template,
args={"doc": doc},
)---
7. extend_doctype_class [v16+]
# hooks.py
extend_doctype_class = {
"Sales Invoice": [
"myapp.overrides.sales_invoice.SalesInvoiceMixin"
]
}# myapp/overrides/sales_invoice.py
import frappe
class SalesInvoiceMixin:
def validate(self):
"""Extension methods run as part of the controller chain."""
if self.is_return and not self.return_against:
frappe.throw("Return invoice must reference the original invoice")
def get_custom_report_data(self):
"""Custom methods are available on the doc instance."""
return frappe.db.sql("""
SELECT item_code, qty, rate
FROM `tabSales Invoice Item`
WHERE parent = %s
""", self.name, as_dict=True)---
8. Handling Amendments
# hooks.py
doc_events = {
"Sales Order": {
"after_insert": "myapp.events.sales_order.handle_amendment"
}
}# myapp/events/sales_order.py
import frappe
def handle_amendment(doc, method=None):
"""Detect and handle amended documents."""
if not doc.amended_from:
return # Not an amendment — normal insert
# Copy custom data from the original document
original = frappe.get_doc("Sales Order", doc.amended_from)
doc.db_set("custom_reference", original.custom_reference, update_modified=False)
frappe.msgprint(f"Amendment created from {doc.amended_from}")---
9. Blocking Deletion with on_trash
# hooks.py
doc_events = {
"Customer": {
"on_trash": "myapp.events.customer.prevent_deletion"
}
}# myapp/events/customer.py
import frappe
def prevent_deletion(doc, method=None):
"""ALWAYS check for linked transactions before allowing deletion."""
linked_invoices = frappe.db.count("Sales Invoice", {"customer": doc.name})
if linked_invoices > 0:
frappe.throw(
f"Cannot delete Customer {doc.name}: "
f"{linked_invoices} Sales Invoice(s) exist"
)---
10. Async Processing with frappe.enqueue
# hooks.py
doc_events = {
"Sales Invoice": {
"on_submit": "myapp.events.sales_invoice.schedule_pdf_generation"
}
}# myapp/events/sales_invoice.py
import frappe
def schedule_pdf_generation(doc, method=None):
"""NEVER do slow operations synchronously in event handlers."""
frappe.enqueue(
"myapp.events.sales_invoice.generate_and_email_pdf",
queue="long",
timeout=300,
doc_name=doc.name,
)
def generate_and_email_pdf(doc_name):
"""Runs asynchronously in background worker."""
doc = frappe.get_doc("Sales Invoice", doc_name)
pdf = frappe.get_print(doc.doctype, doc.name, print_format="Standard")
# ... attach and email