Directory Structure
The three top-level directories of a WordPress install, which files core overwrites on update, and why your code only ever lives in wp-content.
plugin_dir_path( __FILE__ ) === trailingslashit( dirname( __FILE__ ) ) === __DIR__ . '/'A WordPress install has three top-level directories and a handful of root files. Two of the directories belong to core; one belongs to you.
| Path | Owner | What it holds |
|---|---|---|
wp-admin/ | core | Every admin screen. Replaced on update. |
wp-includes/ | core | The API — WP_Query, wpdb, the hooks system, block support. Replaced on update. |
wp-content/ | you | Themes, plugins, must-use plugins, uploads, languages. |
wp-config.php | you | Credentials and constants. Never shipped by the updater. |
index.php, wp-load.php, wp-settings.php, wp-blog-header.php | core | The bootstrap. Replaced on update. |
.htaccess | shared | Apache only; WordPress maintains the block between its # BEGIN WordPress markers. |
Inside wp-content
themes/ and plugins/ hold one directory per item. mu-plugins/ holds must-use plugins, loaded automatically before normal plugins and impossible to deactivate from the admin. uploads/ is the Media Library on disk, organised by year and month by default. languages/ holds translation files, upgrade/ is scratch space for the updater.
Why only wp-content
A core update replaces wp-admin, wp-includes and the root PHP files wholesale, so any edit there disappears without warning. wp-content is left alone. That constraint is why WordPress has hooks at all.
What trips people up
Never hard-code paths. Use WP_CONTENT_DIR, WP_PLUGIN_DIR, plugin_dir_path(), get_stylesheet_directory() and wp_upload_dir() — wp-content can legitimately be moved or renamed.
Example
<?php
/**
* Plugin Name: Acme Core
* File: wp-content/plugins/acme-core/acme-core.php
*/
defined( 'ABSPATH' ) || exit;
/*
* Shorthand and longhand for the same string, e.g.
* "/var/www/example.com/wp-content/plugins/acme-core/".
* plugin_dir_path() is literally a wrapper for trailingslashit( dirname( $file ) ).
*/
define( 'ACME_DIR', plugin_dir_path( __FILE__ ) ); // shorthand
// define( 'ACME_DIR', trailingslashit( dirname( __FILE__ ) ) ); // what it wraps
// define( 'ACME_DIR', __DIR__ . '/' ); // the PHP equivalent
// The URL counterpart. There is no dirname() trick for this one: the mapping
// from filesystem path to URL depends on WP_CONTENT_URL, so always use the API.
define( 'ACME_URL', plugin_dir_url( __FILE__ ) );
require_once ACME_DIR . 'includes/class-acme-settings.php';
add_action( 'wp_enqueue_scripts', static function () {
wp_enqueue_style(
'acme-core', // handle
ACME_URL . 'assets/acme.css', // src
array(), // deps
'1.0.0' // ver — cache buster, not decoration
);
} );
// Writing files? Never guess wp-content/uploads; ask.
add_action( 'acme_export', static function () {
$uploads = wp_upload_dir(); // 'basedir' and 'baseurl' are the roots
$target = $uploads['basedir'] . '/acme-exports';
wp_mkdir_p( $target ); // Creates the directory recursively.
} );When to use it
- Auditing an inherited site for edits made directly to core files.
- Deciding where a snippet belongs: theme functions.php, a plugin, or mu-plugins.
- Writing a deploy that syncs only wp-content and leaves core to the updater.
- Setting up .gitignore so uploads and core are excluded from the repository.
More examples
An annotated install
The map to keep in your head when you open an unfamiliar install. If a customisation lives anywhere outside wp-content or wp-config.php, treat it as a bug: the next core update will delete it.
example.com/
├── index.php core — defines WP_USE_THEMES, requires wp-blog-header.php
├── wp-blog-header.php core — loads WP, runs wp(), loads the template
├── wp-load.php core — finds and requires wp-config.php
├── wp-settings.php core — loads the API, mu-plugins, plugins, the theme
├── wp-config.php YOURS — DB credentials, constants, $table_prefix
├── .htaccess YOURS (Apache) — WP owns the # BEGIN WordPress block
├── wp-admin/ core — every admin screen. Do not edit.
├── wp-includes/ core — the whole API. Do not edit.
└── wp-content/ YOURS
├── themes/
│ ├── twentytwentyfive/ bundled; WP_DEFAULT_THEME in 6.8
│ └── acme/ your theme (or a child of the above)
├── plugins/
│ └── acme-core/acme-core.php
├── mu-plugins/ auto-loaded, top-level .php files only
├── uploads/2026/09/ the Media Library on disk
├── languages/ .mo/.po/.l10n.php translation files
├── upgrade/ scratch space used by the updater
└── debug.log appears once WP_DEBUG_LOG is trueMoving wp-content out of the web root layout
The layout used by Bedrock-style projects and by anyone who wants core to be a disposable, Composer-managed dependency. Do this at install time — moving wp-content on a live site breaks every absolute URL already stored in post content.
<?php
// wp-config.php, BEFORE the require of wp-settings.php.
// Order matters: the URL constants are derived from the DIR ones only if you
// define them yourself, so define all four.
define( 'WP_CONTENT_DIR', dirname( __DIR__ ) . '/app' ); // no trailing slash
define( 'WP_CONTENT_URL', 'https://example.com/app' ); // no trailing slash
// Plugins and mu-plugins default to WP_CONTENT_DIR . '/plugins' and
// '/mu-plugins'. Override only if you are moving them somewhere else again.
define( 'WP_PLUGIN_DIR', WP_CONTENT_DIR . '/plugins' );
define( 'WP_PLUGIN_URL', WP_CONTENT_URL . '/plugins' );
define( 'WPMU_PLUGIN_DIR', WP_CONTENT_DIR . '/mu-plugins' );
define( 'WPMU_PLUGIN_URL', WP_CONTENT_URL . '/mu-plugins' );
// Uploads stay inside WP_CONTENT_DIR unless the upload_path option says
// otherwise; ask wp_upload_dir() rather than assuming either way.A must-use plugin, and loading one from a subdirectory
Use this when you need code that is guaranteed to run on every request and cannot be turned off by an editor clicking Deactivate — environment guards, mail interception on staging, or forced constants. The glob loader is needed because subdirectories under mu-plugins are invisible to core.
<?php
/**
* File: wp-content/mu-plugins/000-acme-loader.php
*
* Must-use plugins load before normal plugins, cannot be deactivated from the
* admin, and are the right home for environment policy you never want a client
* switching off. Note: activation hooks do NOT run for mu-plugins.
*/
defined( 'ABSPATH' ) || exit;
// WordPress only scans PHP files directly inside mu-plugins — not
// subdirectories. This loader is the standard workaround.
foreach ( glob( WPMU_PLUGIN_DIR . '/acme/*.php' ) ?: array() as $acme_file ) {
require_once $acme_file;
}
// Example policy: no code editing on any environment.
if ( ! defined( 'DISALLOW_FILE_EDIT' ) ) {
define( 'DISALLOW_FILE_EDIT', true );
}
Discussion