Adding a credit check to Sales Order shouldn’t mean editing ERPNext’s own controller, because that edit blocks your next update. What you do instead is tell Frappe to call a function of yours whenever a Sales Order is validated. That instruction goes in hooks.py, which bench new-app creates at apps/<app>/<app>/hooks.py with most of the options stubbed out as comments.
How Frappe reads the file
A hook is plain Python: a module-level variable with a known name, holding a string, list or dict. Frappe doesn’t run your hooks in isolation. It loads the file from every app installed on the site and merges values that share a name into lists, which is why ERPNext’s doc_events and yours can both fire on the same save.
Some hooks use every collected value, like included JS files or document events. Others can only have one winner, like override_whitelisted_methods. For those, the app installed last on the site wins. The order can be changed from the Installed Applications page if you really need to.
Outside developer mode, the merged hooks are cached. Editing the file on a production bench does nothing visible until the cache is cleared and the processes restart. bench migrate clears the cache, and the docs say scheduler changes specifically need migrate before they take effect.
A realistic example
# acme_custom/hooks.py
app_name = "acme_custom"
app_title = "Acme Custom"
required_apps = ["erpnext"]
doc_events = {
"Sales Order": {
"validate": "acme_custom.sales_order.check_credit_hold",
"on_submit": "acme_custom.sales_order.notify_warehouse",
},
}
scheduler_events = {
"daily": ["acme_custom.tasks.close_stale_quotations"],
"cron": {
"0 7 * * 1": ["acme_custom.tasks.send_weekly_ar_summary"],
},
}
doctype_js = {"Sales Order": "public/js/sales_order.js"}
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "Acme Custom"]]},
]
# acme_custom/sales_order.py
import frappe
def check_credit_hold(doc, method=None):
if frappe.db.get_value("Customer", doc.customer, "custom_credit_hold"):
frappe.throw(f"{doc.customer} is on credit hold. Ask accounts to release it.")
def notify_warehouse(doc, method=None):
...
Every doc_events handler receives the document and the event name. The event keys match controller method names, so validate, on_update, on_submit and on_cancel all work, and "*" in place of a DocType name applies a handler to every DocType. This is the pattern erpfly generates when a requirement touches a standard ERPNext document: the logic sits in your app, wired through hooks.py, and ERPNext’s files stay untouched.
Hooks you’ll reach for first
doc_eventsfor reacting to saves, submits and cancels on DocTypes you don’t own.scheduler_eventswithhourly,daily,weekly,monthly, their_longvariants for slow jobs, andcronfor exact times.fixturesto ship Custom Fields and other records with the app.doctype_jsto extend a standard form’s client script.override_doctype_classto swap a DocType’s controller class. On v16, the docs recommendextend_doctype_classwhen you only need to add behaviour, because several apps can extend the same class without fighting.after_installandafter_migratefor setup code.
When a hook doesn’t fire
Check the dotted path first. A typo in acme_custom.sales_order.check_credit_hold fails at runtime, not when the file loads. Then check that the app is actually installed on that site with bench --site <site> list-apps, since installing on the bench alone isn’t enough. After that, clear the cache and restart.
If you’re weighing a hook in a custom Frappe app against a Server Script for the same rule, we lay out where each one fits in Server Scripts vs a custom app. For larger builds, see ERPNext custom module development.