PHPackages                             brianhenryie/bh-wp-private-uploads - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [File &amp; Storage](/categories/file-storage)
4. /
5. brianhenryie/bh-wp-private-uploads

ActiveLibrary[File &amp; Storage](/categories/file-storage)

brianhenryie/bh-wp-private-uploads
==================================

WordPress library for serving private/protected file uploads.

0.4.0(1mo ago)326.4k↑430.3%[9 PRs](https://github.com/BrianHenryIE/bh-wp-private-uploads/pulls)1GPL-2.0-or-laterPHPPHP &gt;=8.1

Since Apr 21Pushed 3w ago2 watchersCompare

[ Source](https://github.com/BrianHenryIE/bh-wp-private-uploads)[ Packagist](https://packagist.org/packages/brianhenryie/bh-wp-private-uploads)[ RSS](/packages/brianhenryie-bh-wp-private-uploads/feed)WikiDiscussions master Synced 2w ago

READMEChangelog (6)Dependencies (119)Versions (50)Used By (1)

[![PHP](https://camo.githubusercontent.com/0aa406681a2defd6e07476f8eaba3a362addecd2caa890e6d610eedf0325744d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048502d382e302d3737374242343f6c6f676f3d706870266c6f676f436f6c6f723d7768697465)](https://camo.githubusercontent.com/0aa406681a2defd6e07476f8eaba3a362addecd2caa890e6d610eedf0325744d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048502d382e302d3737374242343f6c6f676f3d706870266c6f676f436f6c6f723d7768697465) [![WordPress tested 6.9](https://camo.githubusercontent.com/463fa70a795b53a9ee0239e2ef5e3a5b983430e8295db3fbdd75c51556e89de3/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f576f726450726573732d76362e392532307465737465642d3030373361612e737667)](#) [![PHPCS WPCS](https://camo.githubusercontent.com/c33b04161cac29bdc3a50b0920a3e07fbe9d034e5bf956d30b42821b2d55092e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f50485043532d576f72645072657373253230436f64696e672532305374616e64617264732532302d3838393242462e737667)](https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards) [![PHPUnit ](.github/coverage.svg)](https://brianhenryie.github.io/bh-wp-private-uploads/) [![PHPStan ](https://camo.githubusercontent.com/da44494d619aa8e3e1a52136f46d36b289bfbd2d6c723437753d0f079b69da50/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d4c6576656c25323031302532302d3261356561372e737667)](https://github.com/szepeviktor/phpstan-wordpress)

BH WP Private Uploads
=====================

[](#bh-wp-private-uploads)

A library to easily create a WordPress uploads subdirectory whose contents cannot be publicly downloaded. Based on [Chris Dennis](https://github.com/StarsoftAnalysis) 's brilliant [Private Uploads](wordpress.org/plugins/private-uploads/) plugin. Adds convenience functions for uploading files to the protected directory, CLI and REST API commands, and displays an admin notice if the directory is public.

### Intro

[](#intro)

I've needed this in various plugins and libraries:

e.g.

- [BH WP Logger](https://github.com/BrianHenryIE/bh-wp-logger) needs the "logs" directory to be private
- [BH WP Mailboxes](https://github.com/BrianHenryIE/bh-wp-mailboxes) needs the "attachments" directory to be private
- BH WC Auto Print Shipping Labels &amp; Receipts needs its PDF directory to be private

The main feature is that it regularly runs a HTTP request to confirm the directory is protected. If it's not, it displays an admin notice.

Then, it allows admins to download all files from that directory and has a filter to allow other users to access the files.

It duplicates the Media/attachment UI, and metaboxes can be added to custom post types for uploading files.

It's far from polished, but there's a lot going on that's not mentioned in this README.

NB: Expect breaking changes with every release until v1.0.0.

If you decide to use this, I'm happy to jump on a call to talk about the direction of the library and how it can be improved.

The main feature in-progress is to allow files to be tied to a specific user, and to allow broader permissions based on the parent post. Specifically to enable GDPR-compliant auto-deletion.

### Install

[](#install)

`composer require brianhenryie/bh-wp-private-uploads`

The following code expects you're prefixing your libraries' namespaces with a tool such as [brianhenryie/strauss](https://github.com/BrianHenryIE/strauss/).

### Instantiate

[](#instantiate)

The following code will create a folder, `wp-content/uploads/my-plugin`, with a `.htaccess` protecting it (via WordPress rewrite rules), and creates a cron job to verify the URL is protected, otherwise it displays an admin notice warning the site admin.

```
$settings = new class() implements \BrianHenryIE\WP_Private_Uploads\Private_Uploads_Settings_Interface {
	use \BrianHenryIE\WP_Private_Uploads\Private_Uploads_Settings_Trait;

	public function get_plugin_slug(): string {
		return 'my-plugin';
	}
};
$private_uploads = \BrianHenryIE\WP_Private_Uploads\Private_Uploads::instance( $settings );
```

The trait provides some sensible defaults based off the plugin slug, which can be easily overridden. It also allows forward-compatability, i.e. methods can be added to the settings interface and defaults provided by the trait.

### Use

[](#use)

That `$private_uploads` instance can be passed around, or the singleton can be accessed anywhere in the code without requiring the settings again.

```
$private_uploads = \BrianHenryIE\WP_Private_Uploads\Private_Uploads::instance();
```

The `\BrianHenryIE\WP_Private_Uploads\API\API` class (which `Private_Uploads` extends) contains convenience functions for downloading and moving files to the private uploads folder. These methods use `wp_handle_upload` behind the scenes and return result objects with file information.

```
// Download `https://example.org/doc.pdf` to `wp-content/uploads/my-plugin/2022/02/target-filename.pdf`.
$result = $private_uploads->download_remote_file_to_private_uploads( 'https://example.org/doc.pdf', 'target-filename.pdf' );
if ( $result->is_success() ) {
    $file_path = $result->get_file();
    $file_url = $result->get_url();
}

// Move `'/local/path/to/doc.pdf` to `wp-content/uploads/my-plugin/2022/02/target-filename.pdf`.
$result = $private_uploads->move_file_to_private_uploads( '/local/path/to/doc.pdf', 'target-filename.pdf' );
if ( $result->is_success() ) {
    $file_path = $result->get_file();
}
```

Important

The `API` class performs **no user-capability checks**. This is deliberate: uploads frequently happen on cron and WP-CLI where there is no logged-in user (user id `0`), and requiring `upload_files` would break those flows. Authorization is enforced at each request boundary instead (the REST controller's `create_item_permissions_check()`, admin-ajax capability checks, and WP-CLI being trusted). **If you expose these methods to a web request, check capabilities yourself first.** Consumer plugins that want an additional guard can hook the `bh_wp_private_uploads_can_upload` filter (return `false` to reject an upload).

The filter is passed the plugin slug and post type name of the instance the file is being uploaded to, so a single handler can distinguish between instances:

```
add_filter( 'bh_wp_private_uploads_can_upload', 'reject_large_uploads', 10, 5 );

/**
 * @param bool $can_upload
 * @param string $tmp_file Source filepath.
 * @param string $filename Destination filename.
 * @param string $plugin_slug
 * @param string $post_type_name
 */
function reject_large_uploads( bool $can_upload, string $tmp_file, string $filename, string $plugin_slug, string $post_type_name ): bool {
	if ( 'my-plugin' !== $plugin_slug ) {
		return $can_upload;
	}
	return $can_upload && filesize( $tmp_file ) < 10_000_000;
}
```

Returning `false` causes the `API` method to throw a `Private_Uploads_Exception`.

The `..._and_create_post` variants additionally create a post of the registered custom post type recording the file – so it appears in the private media library UI – and assign it an owner (`post_author`) and optionally a parent post:

```
// Move the file and record it with a post owned by the user, attached to e.g. a WooCommerce order.
$result = $private_uploads->move_file_to_private_uploads_and_create_post(
    tmp_file: '/local/path/to/doc.pdf',
    filename: 'target-filename.pdf',
    post_author_id: $user_id, // Omit for no owner (`post_author` = `0`).
    post_parent_id: $order_id,
);
$post_id = $result->post_id;

// Download a remote file and record it with a post.
$result = $private_uploads->download_remote_file_to_private_uploads_and_create_post(
    file_url: 'https://example.org/doc.pdf',
    filename: 'target-filename.pdf',
    post_author_id: $user_id,
);
```

By default, administrators can access the files via their URL. This can be widened to more users with the filter:

```
/**
 * Allow filtering for other users.
 *
 * @param bool $should_serve_file
 * @param string $file
 * @param string $plugin_slug
 * @param string $post_type_name
 */
$should_serve_file = apply_filters( 'bh_wp_private_uploads_allow', $should_serve_file, $file, $plugin_slug, $post_type_name );
```

e.g. WooCommerce plugins probably always want shop-managers to be able to access files:

```
add_filter( 'bh_wp_private_uploads_allow', 'add_shop_manager_to_allow', 10, 3 );
function add_shop_manager_to_allow( bool $should_serve_file, string $file, string $plugin_slug ): bool {
	if ( 'my-plugin' !== $plugin_slug ) {
		return $should_serve_file;
	}
	return $should_serve_file || current_user_can( 'manage_woocommerce' );
}
```

### Hooks

[](#hooks)

Every hook is passed the `$plugin_slug` and `$post_type_name` of the instance it fired for as its final two arguments, so one callback can serve several instances (or ignore the ones it does not own). Remember WordPress passes only the first argument unless you declare the argument count in `add_filter()` / `add_action()`.

HookTypeArguments`bh_wp_private_uploads_can_upload`filter`$can_upload`, `$tmp_file`, `$filename`, `$plugin_slug`, `$post_type_name``bh_wp_private_uploads_allow`filter`$should_serve_file`, `$file`, `$plugin_slug`, `$post_type_name``bh_wp_private_uploads_url_is_public_warning`filter`$content`, `$url`, `$plugin_slug`, `$post_type_name``bh_wp_private_uploads_rest_upload`action`$file`, `$request`, `$plugin_slug`, `$post_type_name`The earlier per-post-type hook names – `bh_wp_private_uploads_{$post_type_name}_allow`, `bh_wp_private_uploads_url_is_public_warning_{$post_type_name}` and `rest_private_uploads_upload` – still fire, but are deprecated as of 0.4.0 and will be removed in a future release.

### Advanced

[](#advanced)

#### Folder Name

[](#folder-name)

The folder name can easily be changed, e.g. `wp-content/uploads/email-attachments`:

```
$settings = new class() implements \BrianHenryIE\WP_Private_Uploads\API\Private_Uploads_Settings_Interface {
	use \BrianHenryIE\WP_Private_Uploads\API\Private_Uploads_Settings_Trait;

	public function get_plugin_slug(): string {
		return 'my-plugin';
	}

  /**
	 * Defaults to the plugin slug when using Private_Uploads_Settings_Trait.
	 */
	public function get_uploads_subdirectory_name(): string {
		return 'email-attachments';
	}

};
$private_uploads = \BrianHenryIE\WP_Private_Uploads\Private_Uploads::instance( $settings );
```

#### CLI Command

[](#cli-command)

A CLI command is easily added during configuration:

```
$settings = new class() implements \BrianHenryIE\WP_Private_Uploads\API\Private_Uploads_Settings_Interface {
	use \BrianHenryIE\WP_Private_Uploads\API\Private_Uploads_Settings_Trait;

	public function get_plugin_slug(): string {
		return 'my-plugin';
	}

	/**
	 * Defaults to no CLI commands when using Private_Uploads_Settings_Trait.
	 */
	public function get_cli_base(): ?string {
		return 'my-plugin';
	}

};
$private_uploads = \BrianHenryIE\WP_Private_Uploads\Private_Uploads::instance( $settings );
```

```
wp my-plugin download https://example.org/doc.pdf

```

#### !Singleton

[](#singleton)

There's no need to use the singleton.

```
$private_uploads = new Private_Uploads( $private_uploads_settings, $logger );
// Add the hooks:
new BH_WP_Private_Uploads_Hooks( $private_uploads, $private_uploads_settings, $logger );
```

#### Quick Test

[](#quick-test)

To quickly test the URL is private with cURL:

```
curl -o /dev/null --silent --head --write-out '%{http_code}\n' http://localhost:8080/bh-wp-private-uploads/wp-content/uploads/private/private.txt

```

### Contributing

[](#contributing)

See [CONTRIBUTING.md](https://github.com/BrianHenryIE/bh-wp-private-uploads/blob/master/CONTRIBUTING.md).

### Status

[](#status)

TODO:

- Test API and serve private files classes
- Focus settings on the post type, not the plugin slug (maybe rename the settings interface to reflect this)
- Instantiate the hooks with API class as the parameter, not the Settings (i.e. avoid situation where wires could be crossed)
- Add documentation &amp; screenshots for the media upload UI
- Update this documentation to include post type object filter.
- Verify all test steps in this README
- Test with bh-wp-logger
- Some amount of PHPUnit, WPCS, PhpStan done, but lots to do Now thoroughly PHPCS + PHPStan and lots of PHPUnit + Playwright
- User level permissions per file. (custom post type with filepath/url as GUID)
- Acceptance tests:
- Unit test REST endpoint
- Does the rewrite rule work when WordPress is installed in a subdir?
- Add Nginx instructions
- Detect the user's hosting provider
- GDPR deletion
- REST API file upload -&gt; webhook.
- When viewing an individual post edit screen, it should display information about auto-deleting
- Zip development plugin and add PLayground link on PRs &amp; releases

#### Permissions:

[](#permissions)

We already have registered a post type for registering the REST endpoint. For files that need to be tied to a specific user, make them the author of the post For broader permissions, use the parent of the post--- e.g. set the parent post to be the WooCommerce order and anyone who is allowed to view the order can view the file. Also ties into privacy (GDPR deletion etc.)

###  Health Score

51

—

FairBetter than 95% of packages

Maintenance93

Actively maintained with recent releases

Popularity32

Limited adoption so far

Community14

Small or concentrated contributor base

Maturity53

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 92.3% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~162 days

Recently: every ~198 days

Total

6

Last Release

38d ago

PHP version history (3 changes)0.1PHP &gt;=7.4

0.2.0PHP &gt;=8.0

0.4.0PHP &gt;=8.1

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/4720401?v=4)[Brian Henry](/maintainers/BrianHenryIE)[@BrianHenryIE](https://github.com/BrianHenryIE)

---

Top Contributors

[![BrianHenryIE](https://avatars.githubusercontent.com/u/4720401?v=4)](https://github.com/BrianHenryIE "BrianHenryIE (505 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (42 commits)")

###  Code Quality

TestsCodeception

Static AnalysisRector

Code StylePHP\_CodeSniffer

### Embed Badge

![Health badge](/badges/brianhenryie-bh-wp-private-uploads/health.svg)

```
[![Health](https://phpackages.com/badges/brianhenryie-bh-wp-private-uploads/health.svg)](https://phpackages.com/packages/brianhenryie-bh-wp-private-uploads)
```

###  Alternatives

[symfony/http-kernel

Provides a structured process for converting a Request into a Response

8.1k886.6M9.7k](/packages/symfony-http-kernel)[matomo/matomo

Matomo is the leading Free/Libre open analytics platform

21.7k39.6k](/packages/matomo-matomo)[tempest/framework

The PHP framework that gets out of your way.

2.3k37.6k21](/packages/tempest-framework)[ecotone/ecotone

Enterprise architecture layer for Laravel and Symfony — CQRS, Event Sourcing, Durable Workflows (Sagas, Orchestrators), Projections, and Outbox messaging via PHP attributes.

568591.1k63](/packages/ecotone-ecotone)[api-platform/metadata

API Resource-oriented metadata attributes and factories

275.5M254](/packages/api-platform-metadata)[wikimedia/parsoid

Parsoid, a bidirectional parser between wikitext and HTML5

200569.1k3](/packages/wikimedia-parsoid)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
