Oh Dear! monitors your entire website, not just the homepage. You'll get a notification as soon as your website is down, a monthly uptime report, a warning a few days before your SSL certificate expires and much more! Start your free 10 day trial now!

A package to create personal data exports

Original – by Freek Van der Herten – 4 minute read

One of the good things that GDPR brought us was the right to data portability. Shortly put, this means that an app should be able to export all data that it has for a user.

Because we have multiple apps at Spatie that need to create such an export, we decided to extract our solution to a package called laravel-personal-data-export. In this blog post, I'd like to introduce the package to you.

Creating an export

Creating a data export can be done anywhere in your app by dispatching the CreatePersonalDataExportJob job.

// somewhere in your app

use Spatie\PersonalDataExport\Jobs\CreatePersonalDataExportJob;

// ...

dispatch(new CreatePersonalDataExportJob(auth()->user());

The package will create a zip containing all personal data. When the zip has been created a link to it will be mailed to the user. By default, the zips are saved in a non-public location, and the user should be logged in to be able to download the zip.

You can configure what data will be exported in the selectPersonalData method on the user model.

// in your User model

public function selectPersonalData(PersonalDataSelection $personalDataSelection) {
    $personalDataSelection
        ->add('user.json', ['name' => $this->name, 'email' => $this->email])
        ->addFile(storage_path("avatars/{$this->id}.jpg");
        ->addFile('other-user-data.xml', 's3');
}

You can add new files to it or copy over existing files (even from remote filesystems). All of this will be done via streams so big files won't pose problems at all.

The package also offers a command named personal-data-export:clean to clean up old data exports.

Some cool code tidbits

The package is built relatively straightforward, but it contains a few cool pieces of code.

Before zipping it, we copy all selected data to a temporary directory. If we're copying files from a remote filesystem we are going to use streams. Here's how that looks like in code (taken from PersonalDataSelection):

$stream = Storage::disk($diskName)->readStream($pathOnDisk);

$pathInTemporaryDirectory = $this->temporaryDirectory->path($pathOnDisk);

file_put_contents($pathInTemporaryDirectory, stream_get_contents($stream), FILE_APPEND);

Here's how we use Laravels storage fakes to test that code (taken from PersonalDataSelectionTest.php)

/** @test */
public function it_can_copy_a_file_from_a_disk_to_the_personal_data_temporary_directory()
{
    $disk = Storage::fake('test-disk');
    $disk->put('my-file.txt', 'my content');

    $this->personalDataSelection->addFile('my-file.txt', 'test-disk');

    $this->assertFileContents($this->temporaryDirectory->path('my-file.txt'), 'my content');
} 

Here's how we stream the zip to the browser (taken from ZipDownloadResponse). This code works for both local and remote filesystems. Notice that we can use the Content-Disposition header to determine which filename the users downloading the zip will get in the browser.

class ZipDownloadResponse extends StreamedResponse
{
    public function __construct(string $filename)
    {
        $disk = Storage::disk(config('personal-data-export.disk'));

        if (! $disk->exists($filename)) {
            abort(404);
        }

        $downloadFilename = auth()->user()
            ? auth()->user()->personalDataExportName()
            : $filename;

        $downloadHeaders = [
            'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0',
            'Content-Type' => 'application/zip',
            'Content-Length' => $disk->size($filename),
            'Content-Disposition' => 'attachment; filename="'.$downloadFilename.'"',
            'Pragma' => 'public',
        ];

        parent::__construct(function () use ($filename, $disk) {
            $stream = $disk->readStream($filename);

            fpassthru($stream);

            if (is_resource($stream)) {
                fclose($stream);
            }
        }, Response::HTTP_OK, $downloadHeaders);
    }
}

In this package I also found a good use case for the void typehint. Let take a look at the ExportsPersonalData interface:

namespace Spatie\PersonalDataExport;

interface ExportsPersonalData
{
    public function selectPersonalData(PersonalDataSelection $personalDataSelection): void;

    public function personalDataExportName(): string;

    public function getKey();
}

That void typehint communicates to the user that nothing should be returned from selectPersonalData. This will make it more clear that the given $personalDataSelection is mutable and we except selection methods to be called on that instance.

In closing

Our package has a few more options not mentioned in this blog post. To learn them, head over to the readme of the package on GitHub.

I hope this package will come of us in your next project. Be sure also to take a look at the list of packages our team has created previously.

Stay up to date with all things Laravel, PHP, and JavaScript.

Follow me on Twitter. I regularly tweet out programming tips, and what I myself have learned in ongoing projects.

Every two weeks I send out a newsletter containing lots of interesting stuff for the modern PHP developer.

Expect quick tips & tricks, interesting tutorials, opinions and packages. Because I work with Laravel every day there is an emphasis on that framework.

Rest assured that I will only use your email address to send you the newsletter and will not use it for any other purposes.