---
title: Porting WordPress Plugins
description: Convert WordPress plugins to EmDash plugins using the Plugin API
---

import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from "@astrojs/starlight/components";

Many WordPress plugins can be ported to EmDash. The plugin model is different—TypeScript instead of PHP, hooks instead of actions/filters, structured storage instead of wp_options—but most functionality maps cleanly.

## Portability Assessment

Not all plugins make sense to port. Assess candidates before starting.

<CardGrid>
	<Card title="Good candidates" icon="approve-check">
		Custom fields, SEO plugins, content processors, admin UI extensions, analytics, social sharing, forms
	</Card>
	<Card title="Poor candidates" icon="close">
		Multisite features, WooCommerce/Gutenberg integrations, plugins that patch WordPress core internals
	</Card>
</CardGrid>

## Plugin Structure Comparison

<Tabs>
<TabItem label="WordPress">
```
wp-content/plugins/my-plugin/
├── my-plugin.php       # Main file with plugin header
├── includes/
│   ├── class-admin.php
│   └── class-api.php
└── admin/
    └── js/
```
</TabItem>
<TabItem label="EmDash">
```
my-plugin/
├── src/
│   ├── index.ts    # Plugin definition (definePlugin)
│   └── admin.tsx   # Admin UI exports (React)
├── package.json
└── tsconfig.json
```
</TabItem>
</Tabs>

## Hooks Mapping

WordPress uses `add_action()` and `add_filter()` with string hook names. EmDash uses typed hooks declared in the plugin definition.

### Lifecycle Hooks

| WordPress                    | EmDash            | Notes                                    |
| ---------------------------- | ------------------- | ---------------------------------------- |
| `register_activation_hook()` | `plugin:install`    | Runs once on first install               |
| Plugin enabled               | `plugin:activate`   | Runs when enabled                        |
| Plugin disabled              | `plugin:deactivate` | Runs when disabled                       |
| `register_uninstall_hook()`  | `plugin:uninstall`  | `event.deleteData` indicates user choice |

### Content Hooks

| WordPress             | EmDash               | Notes                                      |
| --------------------- | ---------------------- | ------------------------------------------ |
| `wp_insert_post_data` | `content:beforeSave`   | Return modified content or throw to cancel |
| `save_post`           | `content:afterSave`    | Side effects after save                    |
| `before_delete_post`  | `content:beforeDelete` | Return `false` to cancel                   |
| `deleted_post`        | `content:afterDelete`  | Cleanup after deletion                     |

<Tabs>
<TabItem label="WordPress">
```php
add_action('save_post', function($post_id, $post, $update) {
    if ($post->post_type !== 'product') return;

    $price = get_post_meta($post_id, 'price', true);
    if ($price > 1000) {
        update_post_meta($post_id, 'is_premium', true);
    }

}, 10, 3);

````
</TabItem>
<TabItem label="EmDash">
```typescript
hooks: {
    "content:afterSave": async (event, ctx) => {
        if (event.collection !== "products") return;

        const price = event.content.price as number;
        if (price > 1000) {
            await ctx.kv.set(`premium:${event.content.id}`, true);
        }
    },
}
````

</TabItem>
</Tabs>

### Media Hooks

| WordPress                    | EmDash             | Notes                 |
| ---------------------------- | -------------------- | --------------------- |
| `wp_handle_upload_prefilter` | `media:beforeUpload` | Validate or transform |
| `add_attachment`             | `media:afterUpload`  | React after upload    |

## Storage Mapping

### Options API → KV Store

<Tabs>
<TabItem label="WordPress">
```php
$api_key = get_option('my_plugin_api_key', '');
update_option('my_plugin_api_key', 'abc123');
delete_option('my_plugin_api_key');
```
</TabItem>
<TabItem label="EmDash">
```typescript
const apiKey = await ctx.kv.get<string>("settings:apiKey") ?? "";
await ctx.kv.set("settings:apiKey", "abc123");
await ctx.kv.delete("settings:apiKey");
```
</TabItem>
</Tabs>

<Aside>
	Use `settings:` prefix for user-configurable values and `state:` prefix for internal plugin state.
</Aside>

### Custom Tables → Storage Collections

<Tabs>
<TabItem label="WordPress">
```php
global $wpdb;
$table = $wpdb->prefix . 'my_plugin_items';

// Insert
$wpdb->insert($table, ['name' => 'Item 1', 'status' => 'active']);

// Query
$items = $wpdb->get_results(
"SELECT \* FROM $table WHERE status = 'active' LIMIT 10"
);

````
</TabItem>
<TabItem label="EmDash">
```typescript
// Declare in plugin definition
storage: {
    items: {
        indexes: ["status", "createdAt"],
    },
},

// In hooks or routes:
await ctx.storage.items.put("item-1", {
    name: "Item 1",
    status: "active",
    createdAt: new Date().toISOString(),
});

const result = await ctx.storage.items.query({
    where: { status: "active" },
    limit: 10,
});
````

</TabItem>
</Tabs>

## Settings Schema

WordPress uses the Settings API for admin forms. EmDash uses a declarative schema that auto-generates UI.

<Tabs>
<TabItem label="WordPress">
```php
add_action('admin_init', function() {
    register_setting('my_plugin', 'my_plugin_api_key');
    add_settings_section('main', 'Settings', null, 'my-plugin');
    add_settings_field('api_key', 'API Key', function() {
        $value = get_option('my_plugin_api_key');
        echo '<input type="text" name="my_plugin_api_key"
              value="' . esc_attr($value) . '">';
    }, 'my-plugin', 'main');
});
```
</TabItem>
<TabItem label="EmDash">
```typescript
admin: {
    settingsSchema: {
        apiKey: {
            type: "secret",
            label: "API Key",
            description: "Your API key from the dashboard",
        },
        enabled: {
            type: "boolean",
            label: "Enabled",
            default: true,
        },
        limit: {
            type: "number",
            label: "Item Limit",
            default: 100,
            min: 1,
            max: 1000,
        },
    },
}
```
</TabItem>
</Tabs>

## Admin UI

WordPress admin pages are PHP. EmDash uses React components.

```tsx title="src/admin.tsx"
import { useState, useEffect } from "react";

export const widgets = {
	summary: function SummaryWidget() {
		const [count, setCount] = useState(0);

		useEffect(() => {
			fetch("/_emdash/api/plugins/my-plugin/status")
				.then((r) => r.json())
				.then((data) => setCount(data.count));
		}, []);

		return <div>Total items: {count}</div>;
	},
};

export const pages = {
	settings: function SettingsPage() {
		// React component for settings page
		return <div>Settings content</div>;
	},
};
```

Register in the plugin definition:

```typescript title="src/index.ts"
admin: {
    entry: "@my-org/my-plugin/admin",
    pages: [{ path: "/settings", label: "Dashboard" }],
    widgets: [{ id: "summary", title: "Summary", size: "half" }],
},
```

## REST API → Plugin Routes

<Tabs>
<TabItem label="WordPress">
```php
register_rest_route('my-plugin/v1', '/items', [
    'methods' => 'GET',
    'callback' => function($request) {
        global $wpdb;
        $items = $wpdb->get_results("SELECT * FROM items LIMIT 50");
        return new WP_REST_Response($items);
    },
]);
```
</TabItem>
<TabItem label="EmDash">
```typescript
routes: {
    items: {
        handler: async (ctx) => {
            const result = await ctx.storage.items.query({ limit: 50 });
            return { items: result.items };
        },
    },
},
```
</TabItem>
</Tabs>

Routes are available at `/_emdash/api/plugins/{plugin-id}/{route-name}`.

## Porting Process

<Steps>

1. **Analyze the WordPress plugin**

   Document what it does: hooks, database operations, admin pages, REST endpoints.

2. **Map to EmDash concepts**

   WordPress hooks → EmDash hooks. `wp_options` → `ctx.kv`. Custom tables → Storage collections. Admin pages → React components. REST endpoints → Plugin routes.

3. **Create the plugin skeleton**

   ```typescript title="src/index.ts"
   import { definePlugin } from "emdash";

   export function createPlugin() {
   	return definePlugin({
   		id: "my-ported-plugin",
   		version: "1.0.0",
   		capabilities: [],
   		storage: {},
   		hooks: {},
   		routes: {},
   		admin: {},
   	});
   }
   ```

4. **Implement in order**

   Storage → Hooks → Admin UI → Routes

5. **Test thoroughly**

   Verify hooks fire correctly, storage works, and admin UI renders.

</Steps>

## Example: Read Time Plugin

<Tabs>
<TabItem label="WordPress">
```php
add_filter('wp_insert_post_data', function($data, $postarr) {
    if ($data['post_type'] !== 'post') return $data;

    $content = strip_tags($data['post_content']);
    $word_count = str_word_count($content);
    $read_time = ceil($word_count / 200);

    if (!empty($postarr['ID'])) {
        update_post_meta($postarr['ID'], '_read_time', $read_time);
    }
    return $data;

}, 10, 2);

````
</TabItem>
<TabItem label="EmDash">
```typescript title="src/index.ts"
export function createPlugin() {
    return definePlugin({
        id: "read-time",
        version: "1.0.0",

        admin: {
            settingsSchema: {
                wordsPerMinute: {
                    type: "number",
                    label: "Words per minute",
                    default: 200,
                    min: 100,
                    max: 400,
                },
            },
        },

        hooks: {
            "content:beforeSave": async (event, ctx) => {
                if (event.collection !== "posts") return;

                const wpm = await ctx.kv.get<number>("settings:wordsPerMinute") ?? 200;
                const text = JSON.stringify(event.content.body || "");
                const readTime = Math.ceil(text.split(/\s+/).length / wpm);

                return { ...event.content, readTime };
            },
        },
    });
}
````

</TabItem>
</Tabs>

<Aside type="tip" title="AI-Assisted Porting">
	Plugin porting is more nuanced than theme porting, but AI agents still help significantly. Provide
	the WordPress plugin code along with EmDash's Plugin API documentation, and the agent can
	generate a reasonable first draft. Complex plugins may need multiple iterations.
</Aside>

## Capabilities

Plugins must declare required capabilities for security sandboxing:

| Capability        | Provides                      | Use Case            |
| ----------------- | ----------------------------- | ------------------- |
| `network:request` | `ctx.http.fetch()`            | External API calls  |
| `content:read`    | `ctx.content.get()`, `list()` | Reading CMS content |
| `content:write`   | `ctx.content.create()`, etc.  | Modifying content   |
| `media:read`      | `ctx.media.get()`, `list()`   | Reading media       |
| `media:write`     | `ctx.media.getUploadUrl()`    | Uploading media     |

## Common Gotchas

**No global state** — Use storage instead of global variables.

**Async everything** — Always `await` storage and API calls.

**No direct SQL** — Use structured storage collections.

**No file system** — Use the media API for files.

## Next Steps

- [Hooks Reference](/plugins/hooks/) — All hooks with signatures
- [Storage API](/plugins/storage/) — Collections and queries
- [Settings](/plugins/settings/) — Settings schema and KV store
- [Admin UI](/plugins/admin-ui/) — Building admin pages
