---
title: "The wp-config Constants That Silently Change What WordPress Does"
url: https://adityaarsharma.com/wp-config-constants-that-change-behaviour/
date: 2026-09-29
modified: 2026-09-03
lang: en
author: "Aditya Sharma"
description: "Nine constants, the exact line of core that reads each one, and what breaks. CONCATENATE_SCRIPTS is forced off on the front end whatever you set it to."
categories:
  - "WordPress"
image: https://adityaarsharma.com/wp-content/uploads/2026/09/437f1cf5-32f0-4ff3-a606-f247dd4c5bf5_2912x1632-1024x574.webp
word_count: 2064
---

# The wp-config Constants That Silently Change What WordPress Does

Set `EMPTY_TRASH_DAYS` to `0` and this line in `wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php`, line 1099, changes what a `DELETE` request does:

![WordPress raises your memory limit without asking.](https://adityaarsharma.com/wp-content/uploads/2026/09/437f1cf5-32f0-4ff3-a606-f247dd4c5bf5_2912x1632-scaled.png)The line is `$supports_trash = ( EMPTY_TRASH_DAYS > 0 );`.

With trash enabled, `DELETE /wp/v2/posts/123` without `?force=true` moves the post to trash and returns it. With `EMPTY_TRASH_DAYS` at zero, the same request permanently deletes the post. Nothing about the request changed. A constant in `wp-config.php` changed what the verb means.

That is the pattern for the nine constants below.

Each of them is read somewhere specific in core, changes something you can name, and has a failure mode that is not in the handbook.

All line numbers are from WordPress 7.1, downloaded from wordpress.org on 3 September 2026 and confirmed current against `api.wordpress.org/core/version-check/1.7/`.

On this page

- EMPTY_TRASH_DAYS- WP_POST_REVISIONS- DISALLOW_FILE_MODS- DISABLE_WP_CRON- WP_DEBUG_LOG- WP_ENVIRONMENT_TYPE- AUTOSAVE_INTERVAL- WP_MEMORY_LIMIT- CONCATENATE_SCRIPTS- The one thing that ties several of these together- The one thing to do- Resources
## EMPTY_TRASH_DAYS
Default 30, defined in `wp-includes/default-constants.php` line 388. Read in eighteen places across core.

![The wp-config.php page in the Common APIs Handbook, with the advanced options list running from table_prefix to WP_ENVIRONMENT_TYPE](https://adityaarsharma.com/wp-content/uploads/2026/09/5d0b9e11-c5b3-4143-946b-d26bd53b548f_2800x1720-scaled.png)developer.wordpress.org, Editing wp-config.php. Screenshot taken 3 September 2026.
### Zero does not mean empty it now
The one that matters is `wp_trash_post()`, `wp-includes/post.php` line 4084:

`wp_trash_post( $post_id = 0 )` opens with `if ( ! EMPTY_TRASH_DAYS ) { return wp_delete_post( $post_id, true ); }`. The second argument is the force-delete flag.

Zero does not mean "empty the trash immediately". It means there is no trash. Every path that would have trashed something deletes it instead, including the admin row action, which `wp-includes/link-template.php` line 1577 relabels for you.

The non-obvious part is in `wp_delete_post()`, line 3851:

- It routes to `wp_trash_post()` only when `! $force_delete`.- And only when the post type is `post` or `page`.- And only when `get_post_status( $post_id )` is not already `trash`.- And only when `EMPTY_TRASH_DAYS` is truthy.
### Custom post types are never routed to trash
Only `post` and `page` get routed to trash. `wp_delete_post()` on a custom post type is permanent regardless of what `EMPTY_TRASH_DAYS` says.

If your cleanup script calls `wp_delete_post()` in a loop over a CPT because it assumes the trash will catch mistakes, it will not.

### What breaks
**What breaks:** automation that relies on trash as an undo. If an agent or a script has delete rights, decide this constant deliberately rather than inheriting it.

It is one of the settings I would pin before pointing anything automated at a live site, alongside the rest of [the guardrails for running an agent against production](https://adityaarsharma.com/guardrails-agent-production-wordpress/).

## WP_POST_REVISIONS
Default `true`, line 392 of `default-constants.php`. `wp_revisions_to_keep()` in `wp-includes/revision.php` line 812 turns `true` into `-1`, meaning unlimited.

![src/wp-includes/revision.php in the wordpress-develop repository, 1139 lines, opening on _wp_post_revision_fields](https://adityaarsharma.com/wp-content/uploads/2026/09/9a66d3d3-4e9a-4882-a975-1ebf83c97489_2800x1720-scaled.png)wordpress-develop trunk, src/wp-includes/revision.php. Screenshot taken 3 September 2026.
### Pruning happens on save, not on setting
Setting it to a number prunes on save, in `wp_save_post_revision()`:

- It computes `$delete = count( $revisions ) - $revisions_to_keep` and returns early if that is below 1.- It takes `array_slice( $revisions, 0, $delete )`, the oldest ones.- It walks that slice and calls `continue` on anything whose `post_name` contains `autosave`.- Everything else goes to `wp_delete_post_revision()`.
### Two things fall out of that loop
Two things fall out of that loop. Autosaves are counted in `count( $revisions )` but skipped by the delete, so an autosave permanently occupies one of your slots.

And the pruning only happens when a revision is saved.

Setting `WP_POST_REVISIONS` to 5 on a site with 40,000 existing revisions deletes nothing until somebody edits a post, and then it only prunes that one post.

### What breaks
**What breaks:** nothing, but the cleanup you expected does not happen. Existing revisions need `wp post delete $(wp post list --post_type=revision --format=ids) --force`, which is not reversible, so take a database dump first.

## DISALLOW_FILE_MODS
Read through one function, `wp_is_file_mod_allowed()` in `wp-includes/load.php`:

It is one line: `return apply_filters( 'file_mod_allowed', ! defined( 'DISALLOW_FILE_MODS' ) || ! DISALLOW_FILE_MODS, $context );`

### Eight call sites, three of them capabilities
It is called from eight places.

Three of them are in `map_meta_cap()`, and they deny the file editors, the whole install and update group for plugins, themes and core, and language pack installation.

The other calls disable the automatic updater and gate translation installs.

### What is not gated
What is not gated: `activate_plugins` and `deactivate_plugins`. They have their own case in `capabilities.php` with no file-mod check.

So an administrator on a locked-down site can still activate any plugin that already exists on disk.

That is deliberate, because deployments put plugins there, but it means the constant is a deployment control rather than a containment boundary.

I go through the rest of the capability mapping in [the capabilities and roles walkthrough](https://adityaarsharma.com/wordpress-capabilities-and-roles-mapped/).

### What breaks
**What breaks:** the update screens go quiet. Site Health will still report available updates, users will see counts they cannot act on, and someone will file a ticket about it.

If you set this, put the deploy path in the same commit message.

## DISABLE_WP_CRON
Checked once, in `_wp_cron()`, `wp-includes/cron.php` line 1049:

It returns 0 when `str_contains( $_SERVER['REQUEST_URI'], '/wp-cron.php' )` is true, or when `DISABLE_WP_CRON` is defined and true.

### It stops the spawn, not the scheduler
It stops the spawn, not the scheduler. `wp_schedule_event()` keeps writing to the `cron` option, the queue keeps growing, and nothing runs until something calls `wp-cron.php` or `wp cron event run`.

If you set this and forget the system cron entry, the symptom is not an error, it is scheduled posts silently missing their time weeks later.

### What changed in 6.9
WordPress 6.9 moved `_wp_cron()` from the `wp_loaded` action to `shutdown`, which is documented in the `@since 6.9.0` tag on `wp_cron()`.

That means cron spawning now happens after the response is sent, unless `ALTERNATE_WP_CRON` is defined, in which case it stays on `wp_loaded`.

Worth knowing if you have an old workaround that hooked around the timing.

**What breaks:** covered in full in [the post on missed schedules and WP-Cron](https://adityaarsharma.com/wordpress-missed-schedule-wp-cron/), and the scheduler comparison is in [WP-Cron against Action Scheduler](https://adityaarsharma.com/wordpress-cron-vs-action-scheduler/).

The short version: set `DISABLE_WP_CRON` and a real cron entry in the same change, never one without the other.

Newsletter

## Agents in Production

I check the things our industry takes on trust and publish what I actually found, including when it makes my own work look worse. One researched piece a week.

Email address

Get it weekly

Free. One email a week. Unsubscribe in one click, and I do not send anything else.

## WP_DEBUG_LOG
The whole of the logging logic, from `wp_debug_mode()` in `wp-includes/load.php`:

- Everything sits inside `if ( WP_DEBUG )`, which starts by calling `error_reporting( E_ALL )`.- `WP_DEBUG_DISPLAY` then sets `display_errors` to 1 when truthy, and to 0 when it is anything other than `null`.- If `strtolower( (string) WP_DEBUG_LOG )` is `true` or `1`, the path becomes `WP_CONTENT_DIR . '/debug.log'`.- If `WP_DEBUG_LOG` is a string, that string is the path. Otherwise the path is `false`.- Where there is a path, it sets `log_errors` to 1 and `error_log` to that path.
### Why the log stays empty
Everything is inside `if ( WP_DEBUG )`. `WP_DEBUG_LOG` set to true with `WP_DEBUG` false does nothing at all. That is the single most common reason a debug log stays empty.

### The string branch is the useful one
The string branch is the useful one. Pass a path and the log goes there, which lets you put it outside the web root:

- `define( 'WP_DEBUG', true );`- `define( 'WP_DEBUG_DISPLAY', false );`- `define( 'WP_DEBUG_LOG', '/var/log/wp/site-debug.log' );`
### The default path is publicly readable
The default of `WP_CONTENT_DIR . '/debug.log'` is publicly readable on most hosts unless something blocks it. That has been a real disclosure route for years and it is worth checking on any site you inherit.

**What breaks:** the last block of `wp_debug_mode()` forces `display_errors` off for XML-RPC, REST, AJAX and JSON requests regardless of your settings, because a PHP notice in a JSON body breaks the client.

So errors visible in a browser page can be invisible over REST. Look in the log, not the response.

## WP_ENVIRONMENT_TYPE
`wp_get_environment_type()` in `load.php` accepts exactly four values, and the fallback is the part to know:

The allowed set is `array( 'local', 'development', 'staging', 'production' )`. The environment variable is read first and the constant overrides it.

Then, under core's own comment about not being accidentally set to an invalid value, `if ( ! in_array( $current_env, $wp_environments, true ) ) { $current_env = 'production'; }`.

### A typo silently resolves to production
A typo does not raise anything. `'Staging'` with a capital S, or `'dev'`, silently resolves to `production`. Check it with `wp eval 'echo wp_get_environment_type();'` rather than trusting the config file.

### What development actually switches on
Setting it to `development` turns `WP_DEBUG` on by default, from `default-constants.php` line 93. And WordPress 7.1 added a behaviour that is genuinely new. `wp_should_disable_pings_for_environment()` in `wp-includes/comment.php`, tagged `@since 7.1.0`:

`wp_should_disable_pings_for_environment()` reads `wp_get_environment_type()` and sets `$should_disable` to `'production' !== $environment_type`.

Outgoing pings are removed on any non-production environment, hooked to `do_all_pings` at priority 1. That is a real improvement, and it only works if the constant is spelled correctly, which brings us back to the silent fallback.

**What breaks:** if you clone production to staging and forget this constant, the staging copy is a production environment as far as WordPress is concerned, and in 7.1 that includes sending pings.

## AUTOSAVE_INTERVAL
Default `MINUTE_IN_SECONDS`, so 60. Read in exactly three places, all of which hand it to JavaScript: `wp-includes/script-loader.php` line 1962, `wp-admin/edit-form-blocks.php` line 283, and `class-wp-customize-manager.php` line 4924 where it is multiplied by 1000 for the changeset autosave.

### It is a browser-side timer
That is the whole footprint. It is a browser-side timer, not a server-side one. There is no server enforcement, so a client that ignores the value is not prevented from saving more often.

**What breaks:** setting it very high on a busy editorial site trades database writes for lost work, and people notice the second one. Setting it very low on a site with slow admin-ajax makes the editor feel worse, not safer.

## WP_MEMORY_LIMIT
This one surprises people because WordPress raises your PHP memory limit without being asked. From `wp_initial_constants()`:

- If `WP_MEMORY_LIMIT` is undefined and `wp_is_ini_value_changeable( 'memory_limit' )` is false, it is defined as whatever PHP already has.- Otherwise it is `64M` on multisite and `40M` everywhere else.- `WP_MAX_MEMORY_LIMIT` defaults to `256M`.- The `ini_set( 'memory_limit', WP_MEMORY_LIMIT )` call only fires when `$wp_limit_int` is `-1` or is larger than the current limit, and never when the current limit is already `-1`.![wp_initial_constants in default-constants.php defining WP_MEMORY_LIMIT at 40M, 64M on multisite, and WP_MAX_MEMORY_LIMIT at 256M](https://adityaarsharma.com/wp-content/uploads/2026/09/9a61d560-57a2-47bc-bc45-0927da7d7920_2800x1720-scaled.png)wordpress-develop trunk, src/wp-includes/default-constants.php. Screenshot taken 3 September 2026.
### Three numbers, and one direction
Three numbers: 40M single site, 64M multisite, 256M for the admin-side maximum. The `ini_set()` only fires when the requested value is *higher* than what PHP already has.

WordPress will raise your limit and will never lower it, so setting `WP_MEMORY_LIMIT` to `'64M'` on a server with a 512M PHP limit does nothing whatsoever.

That is the most common misunderstanding about this constant.

`WP_MAX_MEMORY_LIMIT` is the one that applies in wp-admin and to image processing, raised by `wp_raise_memory_limit()`.

**What breaks:** nothing directly, but a fatal error at 40M on a site that never defined the constant is WordPress's own default rather than the host's. Check what you have with `wp eval 'echo ini_get("memory_limit");'` before raising anything.

## CONCATENATE_SCRIPTS
Widely recommended for front-end speed. It does not do that. `script-loader.php` line 2478:

`if ( ! isset( $concatenate_scripts ) ) {
$concatenate_scripts = defined( 'CONCATENATE_SCRIPTS' ) ? CONCATENATE_SCRIPTS : true;
if ( ( ! is_admin() && ! did_action( 'login_init' ) ) || ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ) {
$concatenate_scripts = false;
}
}`
### Read the second condition
Read the second condition.

If the request is not in wp-admin and is not the login screen, concatenation is forced off,

whatever the constant says.

Setting `CONCATENATE_SCRIPTS` to true has no effect on a single front-end page view. It only ever applies to wp-admin and wp-login.

### The useful direction is false
Setting it to `false` is the useful direction, and it applies only in the admin: individual files instead of one `load-scripts.php` request, which makes a broken admin script findable in the network tab.

**What breaks:** the belief that this is a front-end optimisation, which sends people looking for gains that are not there.

If the front end is slow the causes are elsewhere, and I measured them on a real site in [what actually makes a WordPress site slow](https://adityaarsharma.com/what-actually-makes-a-wordpress-site-slow/).

## The one thing that ties several of these together
`EMPTY_TRASH_DAYS` is enforced by `wp_scheduled_delete()`, a daily cron event. Here is where that event is registered, in `wp-admin/admin.php` lines 107 and 112:

`if ( ! wp_next_scheduled( 'wp_scheduled_delete' ) && ! wp_installing() ) {
wp_schedule_event( time(), 'daily', 'wp_scheduled_delete' );
}

if ( ! wp_next_scheduled( 'delete_expired_transients' ) && ! wp_installing() ) {
wp_schedule_event( time(), 'daily', 'delete_expired_transients' );
}``wp-admin/admin.php`. Both of WordPress's housekeeping jobs are scheduled from a file that only loads when somebody opens the admin.

On a headless site, or one where the only logins are over WP-CLI, neither event is ever scheduled and neither ever runs.

Trash accumulates and expired transients accumulate, and the constants controlling them are irrelevant because the code that reads them is never called.

That second one has its own consequences, which I unpicked in [what transients actually cost](https://adityaarsharma.com/wordpress-transients-what-they-actually-cost/).

## The one thing to do
Run this on every site you look after and read the output against what you believe is set:

`wp eval '
foreach ( array( "EMPTY_TRASH_DAYS","WP_POST_REVISIONS","DISALLOW_FILE_MODS","DISABLE_WP_CRON",
"WP_DEBUG","WP_DEBUG_LOG","AUTOSAVE_INTERVAL","WP_MEMORY_LIMIT",
"WP_MAX_MEMORY_LIMIT","CONCATENATE_SCRIPTS" ) as $c ) {
printf( "%-22s %s\n", $c, defined($c) ? var_export(constant($c), true) : "(not defined)" );
}
printf( "%-22s %s\n", "environment", wp_get_environment_type() );
printf( "%-22s %s\n", "php memory_limit", ini_get("memory_limit") );
printf( "%-22s %s\n", "wp_scheduled_delete", wp_next_scheduled("wp_scheduled_delete") ?: "NOT SCHEDULED" );'`The last line is the one worth staring at.

If it says `NOT SCHEDULED`, the trash on that site has never been emptied.

## Resources
- [Editing wp-config.php](https://developer.wordpress.org/apis/wp-config-php/) in the Advanced Administration Handbook- [wp_get_environment_type()](https://developer.wordpress.org/reference/functions/wp_get_environment_type/) in the code reference- [wp-includes/default-constants.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/default-constants.php), where the defaults are set- [wp-includes/load.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/load.php), for wp_debug_mode() and wp_is_file_mod_allowed()- [wp config](https://developer.wordpress.org/cli/commands/config/) in the WP-CLI command reference