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.

Syntaxplugin_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.

PathOwnerWhat it holds
wp-admin/coreEvery admin screen. Replaced on update.
wp-includes/coreThe API — WP_Query, wpdb, the hooks system, block support. Replaced on update.
wp-content/youThemes, plugins, must-use plugins, uploads, languages.
wp-config.phpyouCredentials and constants. Never shipped by the updater.
index.php, wp-load.php, wp-settings.php, wp-blog-header.phpcoreThe bootstrap. Replaced on update.
.htaccesssharedApache 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

Example · php
<?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 · text
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 true

Moving 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.

Example · php
<?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.

Example · php
<?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

  • Be the first to comment on this lesson.