Skip to main content
iVentureTeam

stock.forced_placeholder

On Odoo's replenishment screen, an empty Route cell still tells you which route would apply. That is stock.forced_placeholder: a many2one whose placeholder text survives where the standard widget would show nothing.

Siddharth JambukiyaSiddharth JambukiyaOdoo Techno-Functional Consultant
August 19, 2026Updated August 19, 20265 min read
Odoo 19 replenishment list where empty Route cells display muted placeholder text rendered by the stock.forced_placeholder widget.
Technical namestock.forced_placeholder
Field typesmany2one
Viewslist (the replenishment view), form
Modulestock (Inventory)
Used in core5 occurrences across 3 modules, including stock, mrp, purchase_stock, on replenishment views
VersionsOdoo 20.0, Odoo 19.0
No-code setupNo: it is applied in XML on the replenishment views
Alternativesmany2one, package_m2o, many2one_uom

What the Forced Placeholder widget does

The replenishment screen in Odoo Inventory has to communicate something subtle: what will happen if you do nothing. An orderpoint without an explicit Route is not broken; Odoo will pick one (Buy, Manufacture, a transfer route) according to the product's configuration. Leaving that cell blank would hide exactly the information a planner needs.

The stock module's answer is this widget. stock.forced_placeholder is the standard many2one with a one-line template inheritance: where the base web.Many2One template renders the value if present, this one adds an else branch that prints the placeholder text. The XML pairs it with the inherited placeholder_field option pointing at a computed char field (route_id_placeholder), so the muted text in an empty cell is the actual route Odoo would use for that specific product, not a generic hint.

The source's second override is a comment-documented detail: in readonly mode canOpen is forced off, "to remove the wrong link and the hand cursor on hover", because a cell whose visible text is only a placeholder should not look clickable.

What this means for your team

The pattern here matters to anyone configuring replenishment, and to anyone designing views where defaults do the work.

For planners: the muted route text is the difference between "no route configured" (a problem) and "default route applies" (fine). Reading the replenishment list correctly means knowing that muted text = computed default, solid text = explicit override. Odoo even pairs it with decoration-muted in the view so overridden cells stand out.

For view designers: any field where an empty value falls back to a computed default deserves this treatment. Pricelists falling back to the company default, a picking type falling back to the warehouse's, an analytic account falling back to the project's: showing the effective default in placeholder gray converts support tickets ("why did Odoo buy instead of manufacture?") into self-service. The widget lives in stock, but the pattern is fifteen lines to reproduce in any module.

Supported options in Odoo 19

Verified against forced_placeholder.js in the Odoo 19.0 stock module. The widget declares nothing of its own: the descriptor is buildM2OFieldDescription over the wrapper component, so the full many2one option set applies. The table lists the options that matter to its purpose; the rest behave exactly as on the base widget.

OptionTypeWhat it does
placeholder_fieldfield name (char)Inherited from many2one, but essential here: names the char field whose per-record value renders as the placeholder, in the input when editing and in place of the empty value in readonly. Core points it at computed fields like route_id_placeholder.(since Odoo 19.0)
no_createbooleanInherited from many2one: removes both create paths from the dropdown. Core sets it on the replenishment route cell.
no_openbooleanInherited from many2one: removes the link to the selected record. Independent of this widget's automatic link removal in readonly mode.
search_thresholdnumberInherited from many2one: minimum typed characters before the autocomplete searches.(since Odoo 19.0)

Without placeholder_field (or a static placeholder attribute) this widget is pointless. The whole difference from the base many2one is showing placeholder text in the empty state; give it no placeholder and you get the base behavior with extra steps. Core always pairs it with a computed placeholder field.

Working examples

As the replenishment view uses it

<field name="route_id" widget="stock.forced_placeholder"
       options="{'no_create': True, 'no_open': True, 'placeholder_field': 'route_id_placeholder'}"
       decoration-muted="not route_id"/>

Empty cells show the computed default route in muted text; picking a route overrides it in solid text.

The model side of the pair

route_id_placeholder = fields.Char(compute="_compute_route_id_placeholder")

def _compute_route_id_placeholder(self):
    for orderpoint in self:
        orderpoint.route_id_placeholder = orderpoint._get_default_route().display_name or ""

Reusing the pattern on your own default-driven field

<field name="picking_type_id" widget="stock.forced_placeholder"
       options="{'placeholder_field': 'picking_type_placeholder'}"/>

Works on any many2one, provided the stock module is installed.

Fifteen lines, two overrides

The entire widget is two overrides, and each teaches something about Odoo 19's many2one architecture.

The template override is surgical. It inherits web.Many2One with an xpath on the t-if="props.value" node and adds a t-else printing props.placeholder. Everything else, autocomplete, create flows, Search More, is untouched inheritance. This is only possible because Odoo 19 extracted the reusable Many2One component; in 18 the same feature would have meant forking a much larger template, which is presumably why the widget only appeared in 19.

The props override shows the composition pattern. The field component computes computeM2OProps(this.props) and then overrides one key: canOpen: !props.readonly && props.canOpen. Fifteen lines total. If you write custom many2one variants, this file is the smallest complete example of the 19-era recipe: wrapper component, computeM2OProps, buildM2OFieldDescription.

The placeholder renders wherever the value is empty, editable or readonly. In editable mode the base widget would show the placeholder inside the input anyway; the else-branch matters in readonly renderings, which is exactly the replenishment list's default state.

Version compatibility

VersionStatusNotes
Odoo 20.0In developmentNot released. Behavior unchanged on the development branch; props-schema conversion only.
Odoo 19.0VerifiedIntroduced in 19.0; verified against the shipped source.
Odoo 18.0Not availableDoes not exist.
Odoo 17.0Not availableDoes not exist.
Odoo 16.0Not availableDoes not exist.

Upgrade note. The widget does not exist before Odoo 19. Views referencing it on 18 or earlier fall back to a missing-widget warning; backports need the widget file plus its template copied along, adjusted for the pre-19 many2one internals, which differ substantially.

What is changing in Odoo 20

Odoo 20 is expected at Odoo Experience in Brussels, 24 to 26 September 2026. The notes below are read from the public development branch and are not final until release.

Behavior is unchanged on master. The file is converted to the new props-schema API, and its source comment is telling: it inlines a typed copy of the many2one props "still declared old-style" in the base module, a snapshot of Odoo mid-migration. The template branch and the readonly canOpen override are identical. We re-verify against the shipped release.

Common problems and fixes

SymptomCause and fix
The empty cell shows nothing at allNo placeholder is configured: the widget only renders what placeholder_field (or the placeholder attribute) provides. Point placeholder_field at a char field on the record, computed to hold the effective default.
The placeholder text is wrong or staleThe computed placeholder field's logic, not the widget; it prints whatever that field holds. Debug the compute (route selection rules, warehouse config); the widget is display plumbing only.
Users think the muted text is a saved valuePlaceholder text can read like data to untrained eyes. Keep the decoration-muted pairing from core so explicit values render solid and defaults render gray, and mention it in user training.
Missing widget warning outside InventoryThe widget is registered by the stock module. Add stock as a dependency, or copy the fifteen-line widget and template into your own module.
In readonly the value is not clickable anymoreIntended: the widget forces canOpen off in readonly to avoid a link cursor on placeholder-only cells. Use the plain many2one where readonly navigation matters more than the placeholder.

Forced Placeholder widget vs the alternatives

WidgetBest forKey difference
stock.forced_placeholderMany2one cells where an empty value means a computed default appliesKeeps the placeholder visible in the empty state and drops the link in readonly
many2oneThe general record linkShows nothing when empty and readonly; the base this widget wraps
package_m2oStock package fields with context-driven display namesAlters how values display, not how emptiness displays
many2one_uomUnit of measure links tied to a quantityPairs the m2o with product and quantity fields; no placeholder logic

Reach for this widget when emptiness has a meaning worth showing. If empty should simply look empty, the plain many2one is right; if the goal is a computed display for special models, the stock family has purpose-built variants like package_m2o.

Frequently asked questions

What does stock.forced_placeholder do that many2one does not?+
Exactly one thing, per its own source comment: it "displays a placeholder even if the field is currently not selected", meaning empty cells render the placeholder text where the standard widget renders nothing, plus it removes the link cursor in readonly.
Why is the Route column on the replenishment screen gray?+
Gray text there is the placeholder: the route Odoo will apply by default, computed per product into route_id_placeholder. Selecting a route explicitly replaces it with solid text. Muted = default, solid = override.
Can I use it on my own many2one field?+
Yes: set widget="stock.forced_placeholder" with options="{'placeholder_field': 'your_placeholder_char'}", with the stock module installed. Compute the char field to hold whatever default applies.
Does it support the normal many2one options?+
All of them: the descriptor is built with the same helper as the base widget, so no_create, no_open, search_threshold, domains and even the undocumented options work identically.
Why does it only exist since Odoo 19?+
Because Odoo 19 refactored many2one into a reusable component whose template can be inherited surgically. The widget is a fifteen-line demonstration of that new architecture; in 18 the same feature would have required forking the field.

Replenishment settings your planners can trust?

Routes, orderpoints, lead times and the defaults behind every muted placeholder decide whether Odoo buys, makes or moves. We configure Inventory replenishment end to end and make the screen tell your planners the truth about what will happen next.

Tune our replenishment setup

How this page was produced

This page was verified by reading forced_placeholder.js and its template in the Odoo 19.0 stock module, including the xpath else-branch and the readonly canOpen override with its source comment, plus the replenishment view XML that pairs the widget with route_id_placeholder. Absence in 18.0 and the props-schema conversion on the development branch were confirmed on those branches. Spotted an error or a version difference? Tell us and we will correct the page.