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.3.0(3w ago)322.3k↑296.7%[10 PRs](https://github.com/BrianHenryIE/bh-wp-private-uploads/pulls)1GPL-2.0-or-laterPHPPHP &gt;=8.0CI passing

Since Apr 21Pushed 2w ago1 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 4d ago

READMEChangelog (5)Dependencies (92)Versions (49)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();
}
```

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
 */
$should_serve_file = apply_filters( "bh_wp_private_uploads_{$plugin_slug}_allow", $should_serve_file, $file );
```

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

```
add_filter( "bh_wp_private_uploads_{$plugin_slug}_allow", 'add_shop_manager_to_allow' );
function add_shop_manager_to_allow( bool $should_serve_file ): bool {
	return $should_serve_file || current_user_can( 'manage_woocommerce' );
}
```

### 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

50

—

FairBetter than 95% of packages

Maintenance96

Actively maintained with recent releases

Popularity31

Limited adoption so far

Community13

Small or concentrated contributor base

Maturity49

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 94% 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 ~195 days

Total

5

Last Release

23d ago

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

0.2.0PHP &gt;=8.0

### 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 (421 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (27 commits)")

###  Code Quality

TestsBehat

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/symfony

The Symfony PHP framework

31.4k87.2M2.2k](/packages/symfony-symfony)[symfony/http-kernel

Provides a structured process for converting a Request into a Response

8.1k869.4M8.8k](/packages/symfony-http-kernel)[symfony/cache

Provides extended PSR-6, PSR-16 (and tags) implementations

4.2k373.5M3.3k](/packages/symfony-cache)[matomo/matomo

Matomo is the leading Free/Libre open analytics platform

21.7k38.9k](/packages/matomo-matomo)[tempest/framework

The PHP framework that gets out of your way.

2.2k34.4k15](/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.

564576.7k53](/packages/ecotone-ecotone)

PHPackages © 2026

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