Dev.to (PHP)~6 min read·May 6, 2026
EU Digital Product Passport in WooCommerce: Implementing ESPR Compliance with Structured Data and QR Codes
The EU's Ecodesign for Sustainable Products Regulation (ESPR) mandates Digital Product Passports (DPP) for most physical goods sold in Europe starting 2026–2030 (phased by category). If you run a WooCommerce store shipping to EU customers, your products will need machine-readable passports containing lifecycle data, materials, repairability scores, and recycling instructions — all accessible via a physical carrier (QR code, RFID, or datamatrix).
This article walks through how we built a self-hosted WooCommerce plugin to generate DPP-compliant structured data and printable QR codes — no third-party SaaS, no ongoing fees.
A DPP is a structured data record attached to a physical product containing:
Product identity: GTIN/EAN, model, manufacturer, batch/serial
Materials & substances: Bill of materials, hazardous substances (SVHC list)
Repairability: Spare parts availability, repair manual links, disassembly score
Environmental footprint: Carbon footprint (kg CO₂e), energy efficiency class
End-of-life: Recycling instructions, collection points, recyclate content %
Compliance documents: CE declaration, REACH compliance, test reports
The data must be accessible via a carrier (QR code printed on product/packaging) pointing to a URL that returns structured data in a machine-readable format (JSON-LD recommended by the EU).
We store DPP data as WooCommerce product meta, mapped to the EU DPP data model:
<?php
declare(strict_types=1);
class SP_DPP_Data_Model {
public const FIELDS = [
// Identity
'dpp_gtin' => 'sanitize_text_field',
'dpp_manufacturer_name' => 'sanitize_text_field',
'dpp_manufacturer_country' => 'sanitize_text_field',
'dpp_batch_number' => 'sanitize_text_field',
// Materials
'dpp_main_material' => 'sanitize_text_field',
'dpp_recycled_content_pct' => 'absint',
'dpp_hazardous_substances' => 'sanitize_textarea_field',
// Repairability
'dpp_repairability_score' => 'sanitize_text_field',
'dpp_spare_parts_url' => 'esc_url_raw',
'dpp_repair_manual_url' => 'esc_url_raw',
'dpp_warranty_years' => 'absint',
// Environmental
'dpp_carbon_footprint_kg' => 'sanitize_text_field',
'dpp_energy_class' => 'sanitize_text_field',
'dpp_energy_kwh_year' => 'sanitize_text_field',
// End of life
'dpp_recycling_instructions' => 'sanitize_textarea_field',
'dpp_disassembly_time_min' => 'absint',
'dpp_collection_point_url' => 'esc_url_raw',
// Compliance
'dpp_ce_declaration_url' => 'esc_url_raw',
'dpp_reach_compliant' => 'absint',
];
public function save_product_meta( int $product_id ): void {
if ( ! current_user_can( 'edit_product', $product_id ) ) return;
if ( ! isset( $_POST['sp_dpp_nonce'] ) || ! wp_verify_nonce(
sanitize_text_field( wp_unslash( $_POST['sp_dpp_nonce'] ) ),
'sp_dpp_save_' . $product_id
) ) return;
$product = wc_get_product( $product_id );
if ( ! $product ) return;
foreach ( self::FIELDS as $field => $sanitizer ) {
if ( isset( $_POST[ $field ] ) ) {
$value = call_user_func( $sanitizer, wp_unslash( $_POST[ $field ] ) );
$product->update_meta_data( '_' . $field, $value );
}
}
$product->save();
}
public function get_passport_data( int $product_id ): array {
$product = wc_get_product( $product_id );
if ( ! $product ) return [];
$data = [];
foreach ( array_keys( self::FIELDS ) as $field ) {
$data[ $field ] = $product->get_meta( '_' . $field );
}
return $data;
}
}
The QR code points to a public REST endpoint returning JSON-LD:
<?php
declare(strict_types=1);
class SP_DPP_REST_Controller extends WP_REST_Controller {
protected $namespace = 'sp-dpp/v1';
protected $rest_base = 'passport';
public function register_routes(): void {
register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<product_id>[\d]+)', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_passport' ],
'permission_callback' => '__return_true', // Public
'args' => [
'product_id' => [
'validate_callback' => fn($v) => is_numeric($v),
'sanitize_callback' => 'absint',
],
],
],
] );
}
public function get_passport( WP_REST_Request $request ): WP_REST_Response {
$product_id = $request->get_param( 'product_id' );
$product = wc_get_product( $product_id );
if ( ! $product || ! $product->is_visible() ) {
return new WP_REST_Response( [ 'error' => 'Product not found' ], 404 );
}
$model = new SP_DPP_Data_Model();
$data = $model->get_passport_data( $product_id );
$passport = [
'@context' => [
'schema' => 'https://schema.org/',
'dpp' => 'https://digital-product-passport.eu/ns#',
],
'@type' => [ 'schema:Product', 'dpp:DigitalProductPassport' ],
'@id' => get_permalink( $product_id ),
'schema:name' => $product->get_name(),
'schema:gtin' => $data['dpp_gtin'],
'dpp:materials' => [
'dpp:primaryMaterial' => $data['dpp_main_material'],
'dpp:recycledContentPct' => (int) $data['dpp_recycled_content_pct'],
'dpp:hazardousSubstances' => json_decode( $data['dpp_hazardous_substances'] ?: '[]', true ),
],
'dpp:repairability' => [
'dpp:score' => $data['dpp_repairability_score'],
'dpp:sparePartsUrl' => $data['dpp_spare_parts_url'],
'dpp:repairManual' => $data['dpp_repair_manual_url'],
'dpp:warrantyYears' => (int) $data['dpp_warranty_years'],
],
'dpp:environmental' => [
'dpp:carbonFootprintKg' => (float) $data['dpp_carbon_footprint_kg'],
'dpp:energyClass' => $data['dpp_energy_class'],
'dpp:energyKwhYear' => (float) $data['dpp_energy_kwh_year'],
],
'dpp:endOfLife' => [
'dpp:recyclingInstructions' => $data['dpp_recycling_instructions'],
'dpp:disassemblyTimeMin' => (int) $data['dpp_disassembly_time_min'],
'dpp:collectionPointUrl' => $data['dpp_collection_point_url'],
],
'dpp:compliance' => [
'dpp:ceDeclarationUrl' => $data['dpp_ce_declaration_url'],
'dpp:reachCompliant' => (bool) $data['dpp_reach_compliant'],
],
'dpp:generatedAt' => gmdate( 'c' ),
];
$response = new WP_REST_Response( $passport, 200 );
$response->header( 'Content-Type', 'application/ld+json' );
$response->header( 'Cache-Control', 'public, max-age=3600' );
return $response;
}
}
Endpoint URL: https://yourstore.com/wp-json/sp-dpp/v1/passport/123
<?php
declare(strict_types=1);
class SP_DPP_QR_Generator {
public function generate_qr( int $product_id, int $size = 300 ): string {
$passport_url = rest_url( 'sp-dpp/v1/passport/' . $product_id );
$cache_key = 'sp_dpp_qr_' . $product_id . '_' . $size;
$cached = get_transient( $cache_key );
if ( $cached ) return $cached;
require_once SP_DPP_PATH . 'vendor/phpqrcode/qrlib.php';
ob_start();
QRcode::png( $passport_url, false, QR_ECLEVEL_M, 10, 2 );
$raw_png = ob_get_clean();
$data_uri = 'data:image/png;base64,' . base64_encode( $raw_png );
set_transient( $cache_key, $data_uri, WEEK_IN_SECONDS );
return $data_uri;
}
public function save_qr_file( int $product_id ): string {
$upload_dir = wp_upload_dir();
$dpp_dir = trailingslashit( $upload_dir['basedir'] ) . 'sp-dpp-qr/';
if ( ! file_exists( $dpp_dir ) ) {
wp_mkdir_p( $dpp_dir );
file_put_contents( $dpp_dir . 'index.php', '<?php // Silence is golden.' );
}
$filename = 'dpp-qr-' . $product_id . '.png';
require_once SP_DPP_PATH . 'vendor/phpqrcode/qrlib.php';
QRcode::png( rest_url( 'sp-dpp/v1/passport/' . $product_id ), $dpp_dir . $filename, QR_ECLEVEL_M, 10, 2 );
return trailingslashit( $upload_dir['baseurl'] ) . 'sp-dpp-qr/' . $filename;
}
}
<?php
add_filter( 'woocommerce_product_data_tabs', function( array $tabs ): array {
$tabs['sp_dpp'] = [
'label' => __( 'Digital Passport', 'sp-dpp' ),
'target' => 'sp_dpp_product_data',
'priority' => 85,
];
return $tabs;
} );
add_action( 'woocommerce_product_data_panels', function(): void {
global $post;
wp_nonce_field( 'sp_dpp_save_' . $post->ID, 'sp_dpp_nonce' );
echo '<div id="sp_dpp_product_data" class="panel woocommerce_options_panel">';
echo '<div class="options_group">';
woocommerce_wp_text_input([
'id' => 'dpp_gtin',
'label' => __( 'GTIN / EAN', 'sp-dpp' ),
'description' => __( 'Global Trade Item Number', 'sp-dpp' ),
'desc_tip' => true,
'value' => get_post_meta( $post->ID, '_dpp_gtin', true ),
]);
woocommerce_wp_text_input([
'id' => 'dpp_repairability_score',
'label' => __( 'Repairability Score (e.g. 7.5/10)', 'sp-dpp' ),
'value' => get_post_meta( $post->ID, '_dpp_repairability_score', true ),
]);
woocommerce_wp_text_input([
'id' => 'dpp_carbon_footprint_kg',
'label' => __( 'Carbon Footprint (kg CO₂e)', 'sp-dpp' ),
'value' => get_post_meta( $post->ID, '_dpp_carbon_footprint_kg', true ),
]);
echo '</div>';
// QR preview
$qr = new SP_DPP_QR_Generator();
$src = $qr->generate_qr( $post->ID, 200 );
echo '<div class="options_group">';
echo '<p><strong>' . esc_html__( 'DPP QR Code', 'sp-dpp' ) . '</strong></p>';
echo '<img src="' . esc_attr( $src ) . '" width="150" />';
echo '<p><a href="' . esc_url( rest_url( 'sp-dpp/v1/passport/' . $post->ID ) ) . '" target="_blank">'
. esc_html__( 'View Passport JSON-LD', 'sp-dpp' ) . '</a></p>';
echo '</div>';
echo '</div>';
} );
<?php
add_filter( 'woocommerce_csv_product_import_mapping_default_columns', function( array $columns ): array {
return array_merge( $columns, [
'GTIN' => 'dpp_gtin',
'Manufacturer' => 'dpp_manufacturer_name',
'Carbon Footprint kg' => 'dpp_carbon_footprint_kg',
'Recycled Content %' => 'dpp_recycled_content_pct',
'Repairability Score' => 'dpp_repairability_score',
'Energy Class' => 'dpp_energy_class',
] );
} );
add_filter( 'woocommerce_product_import_pre_insert_product_object',
function( WC_Product $product, array $data ): WC_Product {
foreach ( array_keys( SP_DPP_Data_Model::FIELDS ) as $field ) {
if ( isset( $data[ $field ] ) ) {
$product->update_meta_data( '_' . $field, sanitize_text_field( $data[ $field ] ) );
}
}
return $product;
}, 10, 2 );
Year
Categories
2026
Batteries, textiles, electronics
2027
Furniture, steel, cement, chemicals
2028–2030
All remaining categories
The DPP endpoint must stay accessible for the full product lifecycle — typically 10+ years after sale.
DPP SaaS platforms (Circularise, Renoon, Fairly Made) charge €200–500/mo per brand. For most WooCommerce stores, a one-time plugin generating compliant JSON-LD and QR codes covers the full technical requirement without ongoing costs.
The full plugin is on CodeCanyon: EU Digital Product Passport for WooCommerce (ESPR)
Questions about the JSON-LD structure or ESPR data requirements? Drop them in the comments.